From ca41e6d329c87b7220c72a7d5cfb14a82c19b73e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 23 Jun 2026 13:55:38 -0500 Subject: [PATCH 01/14] Refactor SkeletonBuilder init and usage Change SkeletonBuilder to use BaseSkeletonBuilder as the first base class and make its __init__ accept config_path as a keyword-only argument. Initialize the Qt dialog with an explicit parent (parent=parent) and keep BaseSkeletonBuilder.__init__ called with config_path. Update the call site in create_videos to pass config_path=... and parent=self.root. These changes clarify MRO, enforce explicit config_path usage, and ensure the dialog gets the correct parent widget. --- deeplabcut/gui/tabs/create_videos.py | 2 +- deeplabcut/gui/widgets.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index dcbec6a232..e6a99dce59 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -287,4 +287,4 @@ def create_videos(self): def build_skeleton(self, *args): from deeplabcut.gui.widgets import SkeletonBuilder - SkeletonBuilder(self.root.config) + SkeletonBuilder(config_path=self.root.config, parent=self.root) diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index f8a1e4c932..f16e341ecd 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -522,9 +522,9 @@ def display_help(self, *args): ) -class SkeletonBuilder(QtWidgets.QDialog, BaseSkeletonBuilder): - def __init__(self, config_path, parent=None): - QtWidgets.QDialog.__init__(self, parent) +class SkeletonBuilder(BaseSkeletonBuilder, QtWidgets.QDialog): + def __init__(self, *, config_path, parent=None): + QtWidgets.QDialog.__init__(self, parent=parent) self._parent = parent self.setWindowTitle("Skeleton Builder") BaseSkeletonBuilder.__init__(self, config_path) From 324d630f6103bf82447555a1c3dd1507836f5201 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 23 Jun 2026 14:04:07 -0500 Subject: [PATCH 02/14] Pass parent to SkeletonBuilder Update call in label_frames.py to use explicit keyword arguments: config_path=self.root.config and parent=self.root. This ensures the SkeletonBuilder receives the parent GUI context and makes the parameter usage clearer. --- deeplabcut/gui/tabs/label_frames.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py index 509b04fcd3..fcf3421050 100644 --- a/deeplabcut/gui/tabs/label_frames.py +++ b/deeplabcut/gui/tabs/label_frames.py @@ -140,4 +140,4 @@ def check_labels(self): _ = launch_napari(labeled_images, plugin="napari", stack=True) def build_skeleton(self, *args): - SkeletonBuilder(self.root.config) + SkeletonBuilder(config_path=self.root.config, parent=self.root) From 999adef5e94e8119eb392f4d694c832b4f5192cc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 23 Jun 2026 14:07:05 -0500 Subject: [PATCH 03/14] Remove parent argument from SkeletonBuilder call Update LabelFrames.build_skeleton to instantiate SkeletonBuilder without passing parent=self.root. This aligns the call with the updated SkeletonBuilder constructor/signature and avoids supplying the GUI root object. --- deeplabcut/gui/tabs/label_frames.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py index fcf3421050..91e11849ca 100644 --- a/deeplabcut/gui/tabs/label_frames.py +++ b/deeplabcut/gui/tabs/label_frames.py @@ -140,4 +140,4 @@ def check_labels(self): _ = launch_napari(labeled_images, plugin="napari", stack=True) def build_skeleton(self, *args): - SkeletonBuilder(config_path=self.root.config, parent=self.root) + SkeletonBuilder(config_path=self.root.config) From 22d32735f4bb2f46e9dc77ababcc65da66facfd5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 23 Jun 2026 14:17:01 -0500 Subject: [PATCH 04/14] Persist and show SkeletonBuilder in GUI tabs Assign SkeletonBuilder instances to self.skeleton_builder and call show() in both create_videos and label_frames tabs. Also update the import in label_frames to use deeplabcut.gui.widgets.SkeletonBuilder. This ensures the builder window is displayed. --- deeplabcut/gui/tabs/create_videos.py | 6 +++++- deeplabcut/gui/tabs/label_frames.py | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index e6a99dce59..3642f50cb0 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -287,4 +287,8 @@ def create_videos(self): def build_skeleton(self, *args): from deeplabcut.gui.widgets import SkeletonBuilder - SkeletonBuilder(config_path=self.root.config, parent=self.root) + self.skeleton_builder = SkeletonBuilder( + config_path=self.root.config, + parent=self.root, + ) + self.skeleton_builder.show() diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py index 91e11849ca..241863d469 100644 --- a/deeplabcut/gui/tabs/label_frames.py +++ b/deeplabcut/gui/tabs/label_frames.py @@ -18,8 +18,7 @@ from deeplabcut.generate_training_dataset import check_labels from deeplabcut.gui.components import DefaultTab -from deeplabcut.gui.widgets import launch_napari -from deeplabcut.utils.skeleton import SkeletonBuilder +from deeplabcut.gui.widgets import SkeletonBuilder, launch_napari def label_frames(config_path: str | Path | None = None, image_folder: str | None = None): @@ -140,4 +139,8 @@ def check_labels(self): _ = launch_napari(labeled_images, plugin="napari", stack=True) def build_skeleton(self, *args): - SkeletonBuilder(config_path=self.root.config) + self.skeleton_builder = SkeletonBuilder( + config_path=self.root.config, + parent=self.root, + ) + self.skeleton_builder.show() From 17f40731c4cd14d15a1fd6c007908b55138553b5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 23 Jun 2026 14:31:48 -0500 Subject: [PATCH 05/14] Allow positional config_path in SkeletonBuilder Change SkeletonBuilder.__init__ signature in deeplabcut/gui/widgets.py from `def __init__(self, *, config_path, parent=None)` to `def __init__(self, config_path, *, parent=None)` so callers can pass config_path positionally while keeping parent keyword-only. No other behavior changes. --- deeplabcut/gui/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index f16e341ecd..4c94f3b27d 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -523,8 +523,8 @@ def display_help(self, *args): class SkeletonBuilder(BaseSkeletonBuilder, QtWidgets.QDialog): - def __init__(self, *, config_path, parent=None): - QtWidgets.QDialog.__init__(self, parent=parent) + def __init__(self, config_path, *, parent=None): + QtWidgets.QDialog.__init__(self, parent) self._parent = parent self.setWindowTitle("Skeleton Builder") BaseSkeletonBuilder.__init__(self, config_path) From 595df2c1e52e647690528e2aefb7dd490fa266c1 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 09:44:59 -0500 Subject: [PATCH 06/14] Init skeleton_builder and enhance SkeletonBuilder Add skeleton_builder = None to CreateVideos and LabelFrames to ensure the attribute exists and avoid potential attribute errors. In SkeletonBuilder, instantiate FigureCanvas earlier (remove duplicate instantiation) and add an export() override that calls the base export and logs a success message. Also includes a minor whitespace/layout cleanup. --- deeplabcut/gui/tabs/create_videos.py | 2 ++ deeplabcut/gui/tabs/label_frames.py | 1 + deeplabcut/gui/widgets.py | 7 ++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index 3642f50cb0..6d3f9633c6 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -27,6 +27,8 @@ class CreateVideos(DefaultTab): def __init__(self, root, parent, h1_description): super().__init__(root, parent, h1_description) + self.skeleton_builder = None + self.bodyparts_to_use = self.root.all_bodyparts self._set_page() diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py index 241863d469..0bc9476e2d 100644 --- a/deeplabcut/gui/tabs/label_frames.py +++ b/deeplabcut/gui/tabs/label_frames.py @@ -102,6 +102,7 @@ def __init__(self, root, parent, h1_description): super().__init__(root, parent, h1_description) self._set_page() + self.skeleton_builder = None def _set_page(self): self.label_frames_btn = QtWidgets.QPushButton("Label Frames") diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index 4c94f3b27d..becced12b7 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -531,6 +531,8 @@ def __init__(self, config_path, *, parent=None): def build_ui(self): self.fig = Figure() + self.canvas = FigureCanvas(self.fig) + self.ax = self.fig.add_subplot(111) self.ax.axis("off") @@ -545,7 +547,6 @@ def build_ui(self): self.fig.canvas.mpl_connect("pick_event", self.on_pick) - self.canvas = FigureCanvas(self.fig) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.canvas) self.setLayout(layout) @@ -581,3 +582,7 @@ def write_config(self, config_path, cfg): def display(self): # No-op, the dialog is shown/exec'd by the caller pass + + def export(self, *args): + super().export(*args) + self._parent.logger.info("Skeleton exported successfully.") From 0e327cd400490fc1edb3a0bef97dae0ccc4464d2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 10:07:56 -0500 Subject: [PATCH 07/14] Prevent duplicate SkeletonBuilder dialogs Ensure only one SkeletonBuilder dialog is created per tab and properly cleaned up. CreateVideos and LabelFrames now check if self.skeleton_builder is None before instantiating, connect the widget's destroyed signal to _on_skeleton_builder_destroyed to clear the reference, and avoid reopening if already present. In widgets.SkeletonBuilder set WA_DeleteOnClose so the dialog is deleted on close and show a brief statusbar message on export. Also import Qt into widgets where needed. These changes prevent multiple dialog instances, fix cleanup, and provide user feedback on export. --- deeplabcut/gui/tabs/create_videos.py | 15 ++++++++++----- deeplabcut/gui/tabs/label_frames.py | 15 ++++++++++----- deeplabcut/gui/widgets.py | 4 +++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index 6d3f9633c6..0fba518a59 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -73,6 +73,9 @@ def _set_page(self): self.help_button.clicked.connect(self.show_help_dialog) self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) + def _on_skeleton_builder_destroyed(self): + self.skeleton_builder = None + def show_help_dialog(self): dialog = QtWidgets.QDialog(self) layout = QtWidgets.QVBoxLayout() @@ -289,8 +292,10 @@ def create_videos(self): def build_skeleton(self, *args): from deeplabcut.gui.widgets import SkeletonBuilder - self.skeleton_builder = SkeletonBuilder( - config_path=self.root.config, - parent=self.root, - ) - self.skeleton_builder.show() + if self.skeleton_builder is None: + self.skeleton_builder = SkeletonBuilder( + config_path=self.root.config, + parent=self.root, + ) + self.skeleton_builder.destroyed.connect(self._on_skeleton_builder_destroyed) + self.skeleton_builder.show() diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py index 0bc9476e2d..661875f2ca 100644 --- a/deeplabcut/gui/tabs/label_frames.py +++ b/deeplabcut/gui/tabs/label_frames.py @@ -139,9 +139,14 @@ def check_labels(self): labeled_images = (Path(self.root.config).parent / "labeled-data").rglob("*_labeled/*.png") _ = launch_napari(labeled_images, plugin="napari", stack=True) + def _on_skeleton_builder_destroyed(self): + self.skeleton_builder = None + def build_skeleton(self, *args): - self.skeleton_builder = SkeletonBuilder( - config_path=self.root.config, - parent=self.root, - ) - self.skeleton_builder.show() + if self.skeleton_builder is None: + self.skeleton_builder = SkeletonBuilder( + config_path=self.root.config, + parent=self.root, + ) + self.skeleton_builder.show() + self.skeleton_builder.destroyed.connect(self._on_skeleton_builder_destroyed) diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index becced12b7..21b8a472d9 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -19,7 +19,7 @@ from matplotlib.figure import Figure from matplotlib.widgets import Button, LassoSelector, RectangleSelector from PySide6 import QtCore, QtWidgets -from PySide6.QtGui import QAction, QCursor, QStandardItem, QStandardItemModel +from PySide6.QtGui import QAction, QCursor, QStandardItem, QStandardItemModel, Qt from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.auxfun_videos import VideoWriter @@ -527,6 +527,7 @@ def __init__(self, config_path, *, parent=None): QtWidgets.QDialog.__init__(self, parent) self._parent = parent self.setWindowTitle("Skeleton Builder") + self.setAttribute(Qt.WA_DeleteOnClose, True) BaseSkeletonBuilder.__init__(self, config_path) def build_ui(self): @@ -586,3 +587,4 @@ def display(self): def export(self, *args): super().export(*args) self._parent.logger.info("Skeleton exported successfully.") + self._parent.statusbar.showMessage("Skeleton exported successfully.", 5000) From 1d96cafc6aa20a000626ef42569c56c13c7d1b90 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 10:15:08 -0500 Subject: [PATCH 08/14] Handle missing 'skeleton' key in config Use self.cfg.get("skeleton", []) and a local skeleton variable before iterating, replacing direct indexing of self.cfg["skeleton"]. This avoids a KeyError when the config lacks a skeleton entry while preserving existing behavior when skeleton is present. --- deeplabcut/utils/skeleton.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deeplabcut/utils/skeleton.py b/deeplabcut/utils/skeleton.py index e4e7da8eb3..b5175565cf 100644 --- a/deeplabcut/utils/skeleton.py +++ b/deeplabcut/utils/skeleton.py @@ -90,8 +90,9 @@ def __init__(self, config_path): self.inds = set() self.segs = set() # Draw the skeleton if already existent - if self.cfg["skeleton"]: - for bone in self.cfg["skeleton"]: + skeleton = self.cfg.get("skeleton", []) + if skeleton: + for bone in skeleton: pair = np.flatnonzero(self.bpts.isin(bone)) if len(pair) != 2: continue From a7197c70655dbd57b60f29073ddcca625ff64a92 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 10:15:16 -0500 Subject: [PATCH 09/14] Fix logging call --- deeplabcut/gui/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index 21b8a472d9..a20d4bb4e3 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -587,4 +587,4 @@ def display(self): def export(self, *args): super().export(*args) self._parent.logger.info("Skeleton exported successfully.") - self._parent.statusbar.showMessage("Skeleton exported successfully.", 5000) + self._parent.status_bar.showMessage("Skeleton exported successfully.", 5000) From 718ab1a47e8121080b5101d308ea314d78e99463 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 10:44:01 -0500 Subject: [PATCH 10/14] Refactor SkeletonBuilder UI and add logging Introduce logging and make SkeletonBuilder UI parameters configurable. Added import logging and module logger; initialize self._ax and self.df in constructor. Replace local ax and ampl with instance attributes (self._ax, self.ampl) and expose UI parameters (lasso_select_size, clear/export button axes and labels) to avoid hard-coded values. Update build_ui to use the new attributes and wire up LassoSelector and Buttons using configurable axes/text. These changes improve configurability and prepare for further config refactoring. --- deeplabcut/utils/skeleton.py | 45 ++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/deeplabcut/utils/skeleton.py b/deeplabcut/utils/skeleton.py index b5175565cf..375250fc3d 100644 --- a/deeplabcut/utils/skeleton.py +++ b/deeplabcut/utils/skeleton.py @@ -18,6 +18,7 @@ Licensed under GNU Lesser General Public License v3.0 """ +import logging import os import warnings @@ -33,6 +34,8 @@ from deeplabcut.generate_training_dataset.trainingsetmanipulation import drop_likelihood_columns +logger = logging.getLogger(__name__) + # NOTE @C-Achard 2026-03-26 duplicate config read/write functions # should be addressed in config refactor @@ -52,9 +55,18 @@ def write_config(configname, cfg): class SkeletonBuilder: def __init__(self, config_path): + ### Usage parameters + self.lasso_select_size = 5 + self.clear_button_axes = [0.85, 0.55, 0.1, 0.1] + self.clear_button_text = "Clear" + self.export_button_axes = [0.85, 0.45, 0.1, 0.1] + self.export_button_text = "Save" + self.ampl = 1.3 # Amplification factor for the zoomed-in view of the animal + ### self.config_path = config_path self.cfg = read_config(config_path) # Find uncropped labeled data + self._ax = None self.df = None found = False root = os.path.join(self.cfg["project_path"], "labeled-data") @@ -106,28 +118,27 @@ def __init__(self, config_path): def build_ui(self): self.fig = plt.figure() - ax = self.fig.add_subplot(111) - ax.axis("off") + self._ax = self.fig.add_subplot(111) + self._ax.axis("off") lo = np.nanmin(self.xy, axis=0) hi = np.nanmax(self.xy, axis=0) center = (hi + lo) / 2 w, h = hi - lo - ampl = 1.3 - w *= ampl - h *= ampl - ax.set_xlim(center[0] - w / 2, center[0] + w / 2) - ax.set_ylim(center[1] - h / 2, center[1] + h / 2) - ax.imshow(self.image) - ax.scatter(*self.xy.T, s=self.cfg["dotsize"] ** 2) - ax.add_collection(self.lines) - ax.invert_yaxis() - - self.lasso = LassoSelector(ax, onselect=self.on_select) - ax_clear = self.fig.add_axes([0.85, 0.55, 0.1, 0.1]) - ax_export = self.fig.add_axes([0.85, 0.45, 0.1, 0.1]) - self.clear_button = Button(ax_clear, "Clear") + w *= self.ampl + h *= self.ampl + self._ax.set_xlim(center[0] - w / 2, center[0] + w / 2) + self._ax.set_ylim(center[1] - h / 2, center[1] + h / 2) + self._ax.imshow(self.image) + self._ax.scatter(*self.xy.T, s=self.cfg["dotsize"] ** 2) + self._ax.add_collection(self.lines) + self._ax.invert_yaxis() + + self.lasso = LassoSelector(self._ax, onselect=self.on_select) + ax_clear = self.fig.add_axes(self.clear_button_axes) + ax_export = self.fig.add_axes(self.export_button_axes) + self.clear_button = Button(ax_clear, self.clear_button_text) self.clear_button.on_clicked(self.clear) - self.export_button = Button(ax_export, "Export") + self.export_button = Button(ax_export, self.export_button_text) self.export_button.on_clicked(self.export) self.fig.canvas.mpl_connect("pick_event", self.on_pick) From e48d393c73c24f02a81b2308d37b8c41be22dc98 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 10:44:20 -0500 Subject: [PATCH 11/14] Improve skeleton export and lasso selection Add UI feedback on export and harden export logic: wrap export in try/except, log when saving an empty or partially connected skeleton, write config and show a temporary green "Saved N" button label (with timer reference to avoid GC). Make lasso selection resolution/zoom independent by transforming keypoints and lasso verts into display coordinates, querying a KDTree built in display space using self.lasso_select_size, and ensure segments are passed as a list to set_segments before redrawing. --- deeplabcut/utils/skeleton.py | 73 +++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/deeplabcut/utils/skeleton.py b/deeplabcut/utils/skeleton.py index 375250fc3d..eba6bfb434 100644 --- a/deeplabcut/utils/skeleton.py +++ b/deeplabcut/utils/skeleton.py @@ -173,17 +173,51 @@ def read_config(self, config_path): def write_config(self, config_path, cfg): write_config(config_path, cfg) + def _show_export_feedback(self): + if not hasattr(self, "export_button"): + return + + button = self.export_button + canvas = self.fig.canvas + + original_text = button.label.get_text() + original_color = button.ax.get_facecolor() + + n_edges = len(self.cfg.get("skeleton") or []) + button.label.set_text(f"Saved {n_edges}") + button.ax.set_facecolor("#c8e6c9") # light green + canvas.draw_idle() + + def reset_button(): + button.label.set_text(original_text) + button.ax.set_facecolor(original_color) + canvas.draw_idle() + return False # stop Matplotlib timer + + timer = canvas.new_timer(interval=1200) + timer.add_callback(reset_button) + + # Keep a reference so the timer is not garbage-collected. + self._export_feedback_timer = timer + timer.start() + def export(self, *args): - inds_flat = set(ind for pair in self.inds for ind in pair) - unconnected = [i for i in range(len(self.xy)) if i not in inds_flat] - if len(unconnected): - warnings.warn( - "You didn't connect all the bodyparts (which is fine!). This is just a note to let you know.", - stacklevel=2, - ) - # sort to ensure consistent order in config.yaml - self.cfg["skeleton"] = [tuple(self.bpts[list(pair)]) for pair in sorted(self.inds)] - self.write_config(self.config_path, self.cfg) + try: + inds_flat = set(ind for pair in self.inds for ind in pair) + unconnected = [i for i in range(len(self.xy)) if i not in inds_flat] + # if empty, mention we are saving an empty skeleton + if not self.inds: + logger.info("No bodyparts are connected. Saving an empty skeleton.") + elif len(unconnected): + logger.info( + "Not all bodyparts are connected. Note that connecting all bodyparts is not necessary.", + ) + # sort to ensure consistent order in config.yaml + self.cfg["skeleton"] = [tuple(self.bpts[list(pair)]) for pair in sorted(self.inds)] + self.write_config(self.config_path, self.cfg) + self._show_export_feedback() + except Exception as e: + logger.warning(f"Failed to export skeleton: {e}", stacklevel=2) def on_pick(self, event): if event.mouseevent.button == 3: @@ -197,16 +231,27 @@ def on_pick(self, event): self.fig.canvas.draw_idle() def on_select(self, verts): - # self.path = Path(verts) - # self.verts = verts - inds = self.tree.query_ball_point(verts, 5) + # Transform keypoints and lasso vertices from image/data coordinates + # into display coordinates. This makes the grab radius independent of + # the image resolution and current zoom level. + xy_display = self._ax.transData.transform(self.xy) + verts_display = self._ax.transData.transform(np.asarray(verts)) + + tree_display = KDTree(xy_display) + inds = tree_display.query_ball_point( + verts_display, + self.lasso_select_size, + ) + inds_unique = [] for lst in inds: if len(lst) and lst[0] not in inds_unique: inds_unique.append(lst[0]) + for pair in zip(inds_unique, inds_unique[1:], strict=False): pair_sorted = tuple(sorted(pair)) self.inds.add(pair_sorted) self.segs.add(tuple(map(tuple, self.xy[pair_sorted, :]))) - self.lines.set_segments(self.segs) + + self.lines.set_segments(list(self.segs)) self.fig.canvas.draw_idle() From a5563d0f3ff7d7d277d71597de4e5eb2cf83cfb4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 10:44:37 -0500 Subject: [PATCH 12/14] Make SkeletonBuilder plot params configurable Refactor SkeletonBuilder plotting code to use instance attributes instead of hard-coded values: rename ax to _ax for encapsulation, use self.clear_button_axes and self.export_button_axes for button placements, use self.clear_button_text and self.export_button_text for labels, and replace the inline ampl constant with self.ampl. Also attach the LassoSelector and all drawing operations to _ax and keep canvas draw_idle(). These changes allow external configuration of button layout, labels, and zoom/amplification behavior. --- deeplabcut/gui/widgets.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index a20d4bb4e3..e0d554d2ff 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -534,16 +534,16 @@ def build_ui(self): self.fig = Figure() self.canvas = FigureCanvas(self.fig) - self.ax = self.fig.add_subplot(111) - self.ax.axis("off") + self._ax = self.fig.add_subplot(111) + self._ax.axis("off") - ax_clear = self.fig.add_axes([0.85, 0.55, 0.1, 0.1]) - ax_export = self.fig.add_axes([0.85, 0.45, 0.1, 0.1]) + ax_clear = self.fig.add_axes(self.clear_button_axes) + ax_export = self.fig.add_axes(self.export_button_axes) - self.clear_button = Button(ax_clear, "Clear") + self.clear_button = Button(ax_clear, self.clear_button_text) self.clear_button.on_clicked(self.clear) - self.export_button = Button(ax_export, "Export") + self.export_button = Button(ax_export, self.export_button_text) self.export_button.on_clicked(self.export) self.fig.canvas.mpl_connect("pick_event", self.on_pick) @@ -556,18 +556,17 @@ def build_ui(self): hi = np.nanmax(self.xy, axis=0) center = (hi + lo) / 2 w, h = hi - lo - ampl = 1.3 - w *= ampl - h *= ampl - - self.ax.set_xlim(center[0] - w / 2, center[0] + w / 2) - self.ax.set_ylim(center[1] - h / 2, center[1] + h / 2) - self.ax.imshow(self.image) - self.ax.scatter(*self.xy.T, s=self.cfg["dotsize"] ** 2) - self.ax.add_collection(self.lines) - self.ax.invert_yaxis() - - self.lasso = LassoSelector(self.ax, onselect=self.on_select) + w *= self.ampl + h *= self.ampl + + self._ax.set_xlim(center[0] - w / 2, center[0] + w / 2) + self._ax.set_ylim(center[1] - h / 2, center[1] + h / 2) + self._ax.imshow(self.image) + self._ax.scatter(*self.xy.T, s=self.cfg["dotsize"] ** 2) + self._ax.add_collection(self.lines) + self._ax.invert_yaxis() + + self.lasso = LassoSelector(self._ax, onselect=self.on_select) self.canvas.draw_idle() def read_config(self, config_path): From 539394b6f225a82828793caf38ab5b9164368066 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 11:50:53 -0500 Subject: [PATCH 13/14] Promote SkeletonBuilder defaults to class attrs Move UI/usage parameters (lasso_select_size, clear/export button axes/text, ampl) from __init__ to class-level attributes in deeplabcut/utils/skeleton.py so they become shared defaults rather than instance-only values. Update tests in tests/utils/test_skeleton.py: attach_fake_canvas now creates an _ax on the Figure and sets its x/y limits to ensure plotting code has an axes to operate on; test_export_sorts_pairs_and_warns_for_unconnected now uses the caplog fixture (caplog.at_level and an assertion on caplog.text) instead of pytest.warns to verify the informational log about unconnected bodyparts. These changes improve default handling and make tests more robust about logging and axis availability. --- deeplabcut/utils/skeleton.py | 16 ++++++++-------- tests/utils/test_skeleton.py | 8 ++++++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/deeplabcut/utils/skeleton.py b/deeplabcut/utils/skeleton.py index eba6bfb434..5d8b598cb7 100644 --- a/deeplabcut/utils/skeleton.py +++ b/deeplabcut/utils/skeleton.py @@ -54,15 +54,15 @@ def write_config(configname, cfg): class SkeletonBuilder: + ### Usage parameters + lasso_select_size = 5 + clear_button_axes = [0.85, 0.55, 0.1, 0.1] + clear_button_text = "Clear" + export_button_axes = [0.85, 0.45, 0.1, 0.1] + export_button_text = "Save" + ampl = 1.3 # Amplification factor for the zoomed-in view of the animal + def __init__(self, config_path): - ### Usage parameters - self.lasso_select_size = 5 - self.clear_button_axes = [0.85, 0.55, 0.1, 0.1] - self.clear_button_text = "Clear" - self.export_button_axes = [0.85, 0.45, 0.1, 0.1] - self.export_button_text = "Save" - self.ampl = 1.3 # Amplification factor for the zoomed-in view of the animal - ### self.config_path = config_path self.cfg = read_config(config_path) # Find uncropped labeled data diff --git a/tests/utils/test_skeleton.py b/tests/utils/test_skeleton.py index c83d5060d1..deba4d3c07 100644 --- a/tests/utils/test_skeleton.py +++ b/tests/utils/test_skeleton.py @@ -41,6 +41,9 @@ def make_test_builder(): def attach_fake_canvas(builder): builder.fig = Figure() + builder._ax = builder.fig.add_subplot(111) + builder._ax.set_xlim(-5, 25) + builder._ax.set_ylim(-5, 5) builder.fig.canvas.draw_idle = lambda: None @@ -136,7 +139,7 @@ def test_clear_resets_indices_segments_and_linecollection(): # --------------------------------------------------------------------- -def test_export_sorts_pairs_and_warns_for_unconnected(monkeypatch): +def test_export_sorts_pairs_and_warns_for_unconnected(monkeypatch, caplog): builder = make_test_builder() builder.config_path = "dummy_config.yaml" builder.xy = np.array( @@ -159,8 +162,9 @@ def fake_write_config(path, cfg): monkeypatch.setattr(skeleton_mod, "write_config", fake_write_config) - with pytest.warns(UserWarning, match="didn't connect all the bodyparts"): + with caplog.at_level("INFO"): builder.export() + assert "Not all bodyparts are connected" in caplog.text assert captured["path"] == "dummy_config.yaml" assert captured["cfg"]["skeleton"] == [ From 0e3ad4eefe443c4e610ebbb7d7986e5fecdb29fb Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 11:51:47 -0500 Subject: [PATCH 14/14] Handle skeleton export failures in GUI Make skeleton export report real success/failure to the Qt UI by returning a boolean from `SkeletonBuilder.export()` and conditionally showing success or failure messages in `gui/widgets.py`. The export path now logs incomplete/empty skeleton states as warnings and returns `False` on exceptions. This changes `lasso_select_size` from 5 to 10 to improve selection usability in the skeleton builder. --- deeplabcut/gui/widgets.py | 10 +++++++--- deeplabcut/utils/skeleton.py | 10 ++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index e0d554d2ff..138787883c 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -584,6 +584,10 @@ def display(self): pass def export(self, *args): - super().export(*args) - self._parent.logger.info("Skeleton exported successfully.") - self._parent.status_bar.showMessage("Skeleton exported successfully.", 5000) + success = super().export(*args) + if success: + self._parent.logger.info("Skeleton exported successfully.") + self._parent.status_bar.showMessage("Skeleton exported successfully.", 5000) + else: + self._parent.logger.warning("Failed to export skeleton.") + self._parent.status_bar.showMessage("Failed to export skeleton.", 5000) diff --git a/deeplabcut/utils/skeleton.py b/deeplabcut/utils/skeleton.py index 5d8b598cb7..8cd9cf43b9 100644 --- a/deeplabcut/utils/skeleton.py +++ b/deeplabcut/utils/skeleton.py @@ -55,7 +55,7 @@ def write_config(configname, cfg): class SkeletonBuilder: ### Usage parameters - lasso_select_size = 5 + lasso_select_size = 10 clear_button_axes = [0.85, 0.55, 0.1, 0.1] clear_button_text = "Clear" export_button_axes = [0.85, 0.45, 0.1, 0.1] @@ -201,23 +201,25 @@ def reset_button(): self._export_feedback_timer = timer timer.start() - def export(self, *args): + def export(self, *args) -> bool: try: inds_flat = set(ind for pair in self.inds for ind in pair) unconnected = [i for i in range(len(self.xy)) if i not in inds_flat] # if empty, mention we are saving an empty skeleton if not self.inds: - logger.info("No bodyparts are connected. Saving an empty skeleton.") + logger.warning("No bodyparts are connected. Saving an empty skeleton.") elif len(unconnected): - logger.info( + logger.warning( "Not all bodyparts are connected. Note that connecting all bodyparts is not necessary.", ) # sort to ensure consistent order in config.yaml self.cfg["skeleton"] = [tuple(self.bpts[list(pair)]) for pair in sorted(self.inds)] self.write_config(self.config_path, self.cfg) self._show_export_feedback() + return True except Exception as e: logger.warning(f"Failed to export skeleton: {e}", stacklevel=2) + return False def on_pick(self, event): if event.mouseevent.button == 3: