From edd389ae7cfa95ea00e692bd0792748bd6c354d6 Mon Sep 17 00:00:00 2001 From: Nat Tabris Date: Thu, 3 Oct 2019 10:28:51 -0400 Subject: [PATCH 1/8] Add options for selecting models in inference gui. --- sleap/config/active.yaml | 18 +++++++++++++++ sleap/gui/active.py | 47 +++++++++++++++++++++++++++------------- sleap/nn/training.py | 8 +++++++ tests/gui/test_active.py | 13 +++++++++++ 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/sleap/config/active.yaml b/sleap/config/active.yaml index fa23b0b48..4ee1f7d05 100644 --- a/sleap/config/active.yaml +++ b/sleap/config/active.yaml @@ -145,6 +145,24 @@ learning: inference: +- name: conf_job + label: Node (confmap) Training Profile + type: list + default: a + options: a,b,c + +- name: paf_job + label: Edge (paf) Training Profile + type: list + default: a + options: a,b,c + +- name: centroid_job + label: Centroid Training Profile + type: list + default: a + options: a,b,c + - name: _predict_frames label: Predict On type: list diff --git a/sleap/gui/active.py b/sleap/gui/active.py index e86ce1bc2..d8c9cde8f 100644 --- a/sleap/gui/active.py +++ b/sleap/gui/active.py @@ -19,6 +19,9 @@ from PySide2 import QtWidgets, QtCore +SELECT_FILE_OPTION = "Select a training profile file..." + + class ActiveLearningDialog(QtWidgets.QDialog): """Active learning dialog. @@ -49,6 +52,10 @@ def __init__( self.labels_filename = labels_filename self.labels = labels self.mode = mode + self._job_filter = None + + if self.mode == "inference": + self._job_filter = lambda job: job.is_trained print(f"Number of frames to train on: {len(labels.user_labeled_frames)}") @@ -162,6 +169,13 @@ def _rebuild_job_options(self): # list default profiles find_saved_jobs(profile_dir, self.job_options) + # Apply any filters + if self._job_filter: + for model_type, jobs_list in self.job_options.items(): + self.job_options[model_type] = [ + (path, job) for (path, job) in jobs_list if self._job_filter(job) + ] + def _update_job_menus(self, init: bool = False): """Updates the menus with training profile options. @@ -176,9 +190,11 @@ def _update_job_menus(self, init: bool = False): if model_type not in self.job_options: self.job_options[model_type] = [] if init: - field.currentIndexChanged.connect( - lambda idx, mt=model_type: self._update_from_selected_job(mt, idx) - ) + + def menu_action(idx, mt=model_type, field=field): + self._update_from_selected_job(mt, idx, field) + + field.currentIndexChanged.connect(menu_action) else: # block signals so we can update combobox without overwriting # any user data with the defaults from the profile @@ -365,6 +381,9 @@ def _get_current_training_jobs(self) -> Dict[ModelOutputType, TrainingJob]: for model_type in self._get_model_types_to_use(): job, _ = self._get_current_job(model_type) + if job is None: + continue + if job.model.output_type != ModelOutputType.CENTROIDS: # update training job from params in form trainer = job.trainer @@ -499,8 +518,9 @@ def _option_list_from_jobs(self, model_type: ModelOutputType): """Returns list of menu options for given model type.""" jobs = self.job_options[model_type] option_list = [name for (name, job) in jobs] + option_list.append("") option_list.append("---") - option_list.append("Select a training profile file...") + option_list.append(SELECT_FILE_OPTION) return option_list def _add_job_file(self, model_type): @@ -548,9 +568,10 @@ def _add_job_file_to_list(self, filename: str, model_type: ModelOutputType): text=f"Profile selected is for training {str(file_model_type)} instead of {str(model_type)}." ).exec_() - def _update_from_selected_job(self, model_type: ModelOutputType, idx: int): + def _update_from_selected_job(self, model_type: ModelOutputType, idx: int, field): """Updates dialog settings after user selects a training profile.""" jobs = self.job_options[model_type] + field_text = field.currentText() if idx == -1: return if idx < len(jobs): @@ -569,17 +590,13 @@ def _update_from_selected_job(self, model_type: ModelOutputType, idx: int): self.form_widget.set_form_data(training_params) # is the model already trained? - has_trained = False - final_model_filename = job.final_model_filename - if final_model_filename is not None: - if os.path.exists(os.path.join(job.save_dir, final_model_filename)): - has_trained = True + is_trained = job.is_trained field_name = f"_use_trained_{str(model_type)}" - # update "use trained" checkbox - self.form_widget.fields[field_name].setEnabled(has_trained) - self.form_widget[field_name] = has_trained - else: - # last item is "select file..." + # update "use trained" checkbox if present + if field_name in self.form_widget.fields: + self.form_widget.fields[field_name].setEnabled(is_trained) + self.form_widget[field_name] = is_trained + elif field_text == SELECT_FILE_OPTION: self._add_job_file(model_type) diff --git a/sleap/nn/training.py b/sleap/nn/training.py index a05f1e013..718756bca 100644 --- a/sleap/nn/training.py +++ b/sleap/nn/training.py @@ -625,6 +625,14 @@ class TrainingJob: newest_model_filename: Union[str, None] = None final_model_filename: Union[str, None] = None + @property + def is_trained(self): + if self.final_model_filename is not None: + path = os.path.join(self.save_dir, self.final_model_filename) + if os.path.exists(path): + return True + return False + @staticmethod def save_json(training_job: "TrainingJob", filename: str): """ diff --git a/tests/gui/test_active.py b/tests/gui/test_active.py index b3c565e4f..c8bc42822 100644 --- a/tests/gui/test_active.py +++ b/tests/gui/test_active.py @@ -31,6 +31,19 @@ def test_active_gui(qtbot, centered_pair_labels): assert ModelOutputType.PART_AFFINITY_FIELD not in jobs +def test_inference_gui(qtbot, centered_pair_labels): + win = ActiveLearningDialog( + labels_filename="foo.json", labels=centered_pair_labels, mode="inference" + ) + win.show() + qtbot.addWidget(win) + + # There aren't any trained models, so there should be no options shown for + # inference + jobs = win._get_current_training_jobs() + assert len(jobs) == 0 + + def test_make_default_training_jobs(): jobs = make_default_training_jobs() From b5156a8e0ce8237abc5aa7e4fa770e9af85e6374 Mon Sep 17 00:00:00 2001 From: Nat Tabris Date: Fri, 4 Oct 2019 12:10:33 -0400 Subject: [PATCH 2/8] Only upsample if there are peaks. --- sleap/nn/peakfinding_tf.py | 90 +++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/sleap/nn/peakfinding_tf.py b/sleap/nn/peakfinding_tf.py index 5c7a1d93e..b3991ac16 100644 --- a/sleap/nn/peakfinding_tf.py +++ b/sleap/nn/peakfinding_tf.py @@ -45,6 +45,49 @@ def impeaksnms_tf(I, min_thresh=0.3): return inds, peak_vals +def upsample_peaks(unrolled_confmaps, peaks, h, w, channel_sample_ind, upsample_factor, win_size): + offset = (win_size - 1) / 2 + + # Get the boxes coordinates centered on the peaks, normalized to image + # coordinates + box_ind = tf.squeeze(tf.cast(channel_sample_ind, tf.int32)) + top_left = ( + tf.cast(peaks[:, 1:3], tf.float32) + + tf.constant([-offset, -offset], dtype="float32") + ) / (h - 1.0) + bottom_right = ( + tf.cast(peaks[:, 1:3], tf.float32) + + tf.constant([offset, offset], dtype="float32") + ) / (w - 1.0) + boxes = tf.concat([top_left, bottom_right], axis=1) + + small_windows = tf.image.crop_and_resize( + unrolled_confmaps, boxes, box_ind, crop_size=[win_size, win_size] + ) + + # Upsample cropped windows + windows = tf.image.resize_bicubic( + small_windows, [upsample_factor * win_size, upsample_factor * win_size] + ) + + windows = tf.squeeze(windows) + + # Find global maximum of each window + windows_peaks = find_maxima_tf(windows) # [row_ind, col_ind] ==> (nc, 2) + + # Adjust back to resolution before upsampling + windows_peaks = tf.cast(windows_peaks, tf.float32) / tf.cast( + upsample_factor, tf.float32 + ) + + # Convert to offsets relative to the original peaks (center of cropped windows) + windows_offsets = windows_peaks - tf.cast(offset, tf.float32) # (nc, 2) + windows_offsets = tf.pad( + windows_offsets, [[0, 0], [1, 1]], mode="CONSTANT", constant_values=0 + ) # (nc, 4) + + # Apply offsets + return tf.cast(peaks, tf.float32) + windows_offsets def find_peaks_tf( confmaps, @@ -68,54 +111,13 @@ def find_peaks_tf( sample_ind = tf.floordiv(channel_sample_ind, c) peaks = tf.concat([sample_ind, y, x, channel_ind], axis=1) # (nc, 4) - # If we have run prediction on low res and need to upsample the peaks # to a higher resolution. Compute sub-pixel accurate peaks # from these approximate peaks and return the upsampled sub-pixel peaks. if upsample_factor > 1: - - offset = (win_size - 1) / 2 - - # Get the boxes coordinates centered on the peaks, normalized to image - # coordinates - box_ind = tf.squeeze(tf.cast(channel_sample_ind, tf.int32)) - top_left = ( - tf.cast(peaks[:, 1:3], tf.float32) - + tf.constant([-offset, -offset], dtype="float32") - ) / (h - 1.0) - bottom_right = ( - tf.cast(peaks[:, 1:3], tf.float32) - + tf.constant([offset, offset], dtype="float32") - ) / (w - 1.0) - boxes = tf.concat([top_left, bottom_right], axis=1) - - small_windows = tf.image.crop_and_resize( - unrolled_confmaps, boxes, box_ind, crop_size=[win_size, win_size] - ) - - # Upsample cropped windows - windows = tf.image.resize_bicubic( - small_windows, [upsample_factor * win_size, upsample_factor * win_size] - ) - - windows = tf.squeeze(windows) - - # Find global maximum of each window - windows_peaks = find_maxima_tf(windows) # [row_ind, col_ind] ==> (nc, 2) - - # Adjust back to resolution before upsampling - windows_peaks = tf.cast(windows_peaks, tf.float32) / tf.cast( - upsample_factor, tf.float32 - ) - - # Convert to offsets relative to the original peaks (center of cropped windows) - windows_offsets = windows_peaks - tf.cast(offset, tf.float32) # (nc, 2) - windows_offsets = tf.pad( - windows_offsets, [[0, 0], [1, 1]], mode="CONSTANT", constant_values=0 - ) # (nc, 4) - - # Apply offsets - peaks = tf.cast(peaks, tf.float32) + windows_offsets + peaks = tf.cond(tf.less(tf.shape(peaks)[0], 1), + lambda: upsample_peaks(unrolled_confmaps, peaks, h, w, channel_sample_ind, upsample_factor, win_size), + lambda: tf.cast(peaks, tf.float32)) return peaks, peak_vals From 86aad642ee8d98946627203adadff16df3028285 Mon Sep 17 00:00:00 2001 From: Nat Tabris Date: Mon, 7 Oct 2019 12:34:44 -0400 Subject: [PATCH 3/8] Use new merge method in active learning. Also, save all the new predictions together in one h5 file. --- sleap/gui/active.py | 40 ++++++++++++---------------------------- tests/gui/test_active.py | 3 ++- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/sleap/gui/active.py b/sleap/gui/active.py index d8c9cde8f..7727b05ee 100644 --- a/sleap/gui/active.py +++ b/sleap/gui/active.py @@ -5,6 +5,7 @@ import os import cattr +from datetime import datetime from functools import reduce from pkg_resources import Requirement, resource_filename from typing import Dict, List, Optional, Tuple @@ -699,28 +700,6 @@ def find_saved_jobs( return jobs -def add_frames_from_json(labels: Labels, new_labels_json: str) -> int: - """Merges new predictions (given as json string) into dataset. - - Args: - labels: The dataset to which we're adding the predictions. - new_labels_json: A JSON string which can be deserialized into `Labels`. - Returns: - Number of labeled frames with new predictions. - """ - # Deserialize the new frames, matching to the existing videos/skeletons if possible - new_lfs = Labels.from_json(new_labels_json, match_to=labels).labeled_frames - - # Remove any frames without instances - new_lfs = list(filter(lambda lf: len(lf.instances), new_lfs)) - - # Now add them to labels and merge labeled frames with same video/frame_idx - labels.extend_from(new_lfs) - labels.merge_matching_frames() - - return len(new_lfs) - - def run_active_learning_pipeline( labels_filename: str, labels: Labels, @@ -879,8 +858,8 @@ def run_active_inference( # from multiprocessing import Pool # total_new_lf_count = 0 - # timestamp = datetime.now().strftime("%y%m%d_%H%M%S") - # inference_output_path = os.path.join(save_dir, f"{timestamp}.inference.h5") + timestamp = datetime.now().strftime("%y%m%d_%H%M%S") + inference_output_path = os.path.join(save_dir, f"{timestamp}.inference.h5") # Create Predictor from the results of training # pool = Pool(processes=1) @@ -942,10 +921,15 @@ def run_active_inference( # Remove any frames without instances new_lfs = list(filter(lambda lf: len(lf.instances), new_lfs)) - # Now add them to labels and merge labeled frames with same video/frame_idx - # labels.extend_from(new_lfs) - labels.extend_from(new_lfs, unify=True) - labels.merge_matching_frames() + # Create and save dataset with predictions + new_labels = Labels(new_lfs) + Labels.save_file(new_labels, inference_output_path) + + # Merge predictions into current labels dataset + _, _, new_conflicts = Labels.complex_merge_between(labels, new_labels) + + # new predictions should replace old ones + Labels.finish_complex_merge(labels, new_conflicts) # close message window if gui: diff --git a/tests/gui/test_active.py b/tests/gui/test_active.py index c8bc42822..15ae11de5 100644 --- a/tests/gui/test_active.py +++ b/tests/gui/test_active.py @@ -1,4 +1,5 @@ import os +import pytest from sleap.skeleton import Skeleton from sleap.instance import Instance, Point, LabeledFrame, PredictedInstance @@ -9,7 +10,6 @@ ActiveLearningDialog, make_default_training_jobs, find_saved_jobs, - add_frames_from_json, ) @@ -80,6 +80,7 @@ def test_find_saved_jobs(): assert os.path.basename(paths[1]) == "default_confmaps.json" +@pytest.mark.skip(reason="for old merging method") def test_add_frames_from_json(): vid_a = Video.from_filename("foo.mp4") vid_b = Video.from_filename("bar.mp4") From bae8aed5e99da65d06ea6bb097f9fd7fb4099107 Mon Sep 17 00:00:00 2001 From: Nat Tabris Date: Mon, 7 Oct 2019 17:01:17 -0400 Subject: [PATCH 4/8] Added back previousLabeledFrameIndex(). --- sleap/gui/app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sleap/gui/app.py b/sleap/gui/app.py index 97e8911fa..2fbc9fb89 100644 --- a/sleap/gui/app.py +++ b/sleap/gui/app.py @@ -2055,6 +2055,17 @@ def _plot_if_next(self, frame_iterator: Iterator) -> bool: self.plotFrame(next_lf.frame_idx) return True + def previousLabeledFrameIndex(self): + cur_idx = self.player.frame_idx + frames = self.labels.frames(self.video, from_frame_idx=cur_idx, reverse=True) + + try: + next_idx = next(frames).frame_idx + except: + return + + return next_idx + def previousLabeledFrame(self): """Goes to labeled frame prior to current frame.""" frames = self.labels.frames( From 7dbf493d74e8e367468f3550d2dd83d198c2a64b Mon Sep 17 00:00:00 2001 From: Nat Tabris Date: Tue, 8 Oct 2019 08:40:25 -0400 Subject: [PATCH 5/8] Draw first predicted node with thicker line. This makes it easier to proofread predictions for skeletons that don't otherwise have obvious orientation. --- sleap/gui/video.py | 50 +++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/sleap/gui/video.py b/sleap/gui/video.py index b1e79deb7..d46544f9a 100644 --- a/sleap/gui/video.py +++ b/sleap/gui/video.py @@ -41,9 +41,9 @@ QGraphicsRectItem, ) -from sleap.skeleton import Skeleton +from sleap.skeleton import Node from sleap.instance import Instance, Point -from sleap.io.video import Video, HDF5Video +from sleap.io.video import Video from sleap.gui.slider import VideoSlider import qimage2ndarray @@ -835,9 +835,17 @@ class QtNodeLabel(QGraphicsTextItem): Args: node: The `QtNode` to which this label is attached. parent: The `QtInstance` which will contain this item. + predicted: Whether this is for a predicted point. """ - def __init__(self, node, parent, predicted=False, *args, **kwargs): + def __init__( + self, + node: Node, + parent: QGraphicsObject, + predicted: bool = False, + *args, + **kwargs, + ): self.node = node self.text = node.name self.predicted = predicted @@ -979,21 +987,25 @@ class QtNode(QGraphicsEllipseItem): Args: parent: The `QtInstance` which will contain this item. - point: The `Point` where this node is located. + node: The :class:`Node` corresponding to this visual node. + point: The :class:`Point` where this node is located. Note that this is a mutable object so we're able to directly access the very same `Point` object that's defined outside our class. radius: Radius of the visual node item. color: Color of the visual node item. + predicted: Whether this point is predicted. + color_predicted: Whether to color predicted points. + show_non_visible: Whether to show points where `visible` is False. callbacks: List of functions to call after we update to the `Point`. """ def __init__( self, - parent, + parent: QGraphicsObject, + node: Node, point: Point, radius: float, color: list, - node_name: str = None, predicted=False, color_predicted=False, show_non_visible=True, @@ -1003,10 +1015,11 @@ def __init__( ): self._parent = parent self.point = point + self.node = node self.radius = radius self.color = color self.edges = [] - self.name = node_name + self.name = node.name self.predicted = predicted self.color_predicted = color_predicted self.show_non_visible = show_non_visible @@ -1023,8 +1036,8 @@ def __init__( **kwargs, ) - if node_name is not None: - self.setToolTip(node_name) + if self.name is not None: + self.setToolTip(self.name) self.setFlag(QGraphicsItem.ItemIgnoresTransformations) @@ -1033,10 +1046,14 @@ def __init__( if self.predicted: self.setFlag(QGraphicsItem.ItemIsMovable, False) + pen_width = 1 + if self.node == self._parent.instance.skeleton.nodes[0]: + pen_width = 3 + if self.color_predicted: - self.pen_default = QPen(col_line, 1) + self.pen_default = QPen(col_line, pen_width) else: - self.pen_default = QPen(QColor(250, 250, 10), 1) + self.pen_default = QPen(QColor(250, 250, 10), pen_width) self.pen_default.setCosmetic(True) self.pen_missing = self.pen_default self.brush = QBrush(QColor(128, 128, 128, 128)) @@ -1179,6 +1196,7 @@ class QtEdge(QGraphicsLineItem): QGraphicsLineItem to handle display of edge between skeleton instance nodes. Args: + parent: `QGraphicsObject` which will contain this item. src: The `QtNode` source node for the edge. dst: The `QtNode` destination node for the edge. color: Color as (r, g, b) tuple. @@ -1187,11 +1205,11 @@ class QtEdge(QGraphicsLineItem): def __init__( self, - parent, + parent: QGraphicsObject, src: QtNode, dst: QtNode, - color, - show_non_visible=True, + color: tuple, + show_non_visible: bool = True, *args, **kwargs, ): @@ -1361,8 +1379,8 @@ def __init__( for (node, point) in self.instance.nodes_points: node_item = QtNode( parent=self, + node=node, point=point, - node_name=node.name, predicted=self.predicted, color_predicted=self.color_predicted, color=self.color, @@ -1535,7 +1553,7 @@ class QtTextWithBackground(QGraphicsTextItem): """ Inherits methods/behavior of `QGraphicsTextItem`, but with background box. - Color of brackground box is light or dark depending on the text color. + Color of background box is light or dark depending on the text color. """ def __init__(self, *args, **kwargs): From a1bcb9bb9b90c78f8f70b486dcbfed2fcda10a18 Mon Sep 17 00:00:00 2001 From: Nat Tabris Date: Tue, 8 Oct 2019 09:00:10 -0400 Subject: [PATCH 6/8] Don't import Signal from QtCore. (Doesn't change functionality but PyCharm wasn't finding Signal.) --- sleap/gui/video.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/sleap/gui/video.py b/sleap/gui/video.py index d46544f9a..946574884 100644 --- a/sleap/gui/video.py +++ b/sleap/gui/video.py @@ -12,7 +12,7 @@ """ -from PySide2 import QtWidgets +from PySide2 import QtWidgets, QtCore from PySide2.QtWidgets import ( QApplication, @@ -24,8 +24,7 @@ from PySide2.QtGui import QImage, QPixmap, QPainter, QPainterPath, QTransform from PySide2.QtGui import QPen, QBrush, QColor, QFont from PySide2.QtGui import QKeyEvent -from PySide2.QtCore import Qt, Signal, Slot -from PySide2.QtCore import QRectF, QPointF, QMarginsF +from PySide2.QtCore import Qt, QRectF, QPointF, QMarginsF import math @@ -64,8 +63,8 @@ class QtVideoPlayer(QWidget): """ - changedPlot = Signal(QWidget, int, Instance) - changedData = Signal(Instance) + changedPlot = QtCore.Signal(QWidget, int, Instance) + changedData = QtCore.Signal(Instance) def __init__(self, video: Video = None, color_manager=None, *args, **kwargs): super(QtVideoPlayer, self).__init__(*args, **kwargs) @@ -328,7 +327,7 @@ def handle_selection( on_each(indexes) @staticmethod - def _signal_once(signal: Signal, callback: Callable): + def _signal_once(signal: QtCore.Signal, callback: Callable): """ Connects callback for next occurrence of signal. @@ -454,17 +453,17 @@ class GraphicsView(QGraphicsView): """ - updatedViewer = Signal() - updatedSelection = Signal() - instanceDoubleClicked = Signal(Instance) - areaSelected = Signal(float, float, float, float) - pointSelected = Signal(float, float) - leftMouseButtonPressed = Signal(float, float) - rightMouseButtonPressed = Signal(float, float) - leftMouseButtonReleased = Signal(float, float) - rightMouseButtonReleased = Signal(float, float) - leftMouseButtonDoubleClicked = Signal(float, float) - rightMouseButtonDoubleClicked = Signal(float, float) + updatedViewer = QtCore.Signal() + updatedSelection = QtCore.Signal() + instanceDoubleClicked = QtCore.Signal(Instance) + areaSelected = QtCore.Signal(float, float, float, float) + pointSelected = QtCore.Signal(float, float) + leftMouseButtonPressed = QtCore.Signal(float, float) + rightMouseButtonPressed = QtCore.Signal(float, float) + leftMouseButtonReleased = QtCore.Signal(float, float) + rightMouseButtonReleased = QtCore.Signal(float, float) + leftMouseButtonDoubleClicked = QtCore.Signal(float, float) + rightMouseButtonDoubleClicked = QtCore.Signal(float, float) def __init__(self, *args, **kwargs): """ https://github.com/marcel-goldschen-ohm/PyQtImageViewer/blob/master/QtImageViewer.py """ @@ -1315,7 +1314,7 @@ class QtInstance(QGraphicsObject): """ - changedData = Signal(Instance) + changedData = QtCore.Signal(Instance) def __init__( self, From c6122f5195e832b91d05fa512c1f5d17eceb48ba Mon Sep 17 00:00:00 2001 From: Nat Tabris Date: Tue, 8 Oct 2019 15:50:20 -0400 Subject: [PATCH 7/8] Bug fix when adding nodes to instance in gui. --- sleap/gui/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sleap/gui/app.py b/sleap/gui/app.py index 2fbc9fb89..e5389d1c8 100644 --- a/sleap/gui/app.py +++ b/sleap/gui/app.py @@ -1545,7 +1545,7 @@ def doubleClickInstance(self, instance: Instance): ).boundingRect() for node in self.skeleton.nodes: - if node.name not in instance.node_names or instance[node].isnan(): + if node not in instance.nodes or instance[node].isnan(): # pick random points within currently zoomed view x = ( in_view_rect.x() From a69a82b8bda51768f61497f1cce719dd5d0338f8 Mon Sep 17 00:00:00 2001 From: Nat Tabris Date: Tue, 8 Oct 2019 16:36:52 -0400 Subject: [PATCH 8/8] Bug fix (typo in function name). --- sleap/gui/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sleap/gui/app.py b/sleap/gui/app.py index e5389d1c8..37243a99a 100644 --- a/sleap/gui/app.py +++ b/sleap/gui/app.py @@ -1495,7 +1495,7 @@ def clearFrameNegativeAnchors(self): def importPredictions(self): """Starts gui for importing another dataset into currently one.""" filters = ["HDF5 dataset (*.h5 *.hdf5)", "JSON labels (*.json *.json.zip)"] - filenames, selected_filter = openFileDialogs( + filenames, selected_filter = openFileDialog( self, dir=None, caption="Import labeled data...",