1
1
import os
2
- from collections import defaultdict
2
+ from collections import defaultdict , namedtuple
3
3
from copy import deepcopy
4
4
from datetime import datetime
5
- from functools import partial
5
+ from functools import partial , cached_property
6
6
from math import ceil , log10
7
7
import pandas as pd
8
8
from pathlib import Path
17
17
from napari .layers .utils .layer_utils import _features_to_properties
18
18
from napari .utils .events import Event
19
19
from napari .utils .history import get_save_history , update_save_history
20
- from qtpy .QtCore import Qt , QTimer , Signal , QSize
21
- from qtpy .QtGui import QPainter , QIcon
20
+ from qtpy .QtCore import Qt , QTimer , Signal , QSize , QPoint , QSettings
21
+ from qtpy .QtGui import QPainter , QIcon , QAction
22
22
from qtpy .QtWidgets import (
23
23
QButtonGroup ,
24
24
QCheckBox ,
25
25
QComboBox ,
26
+ QDialog ,
27
+ QDialogButtonBox ,
26
28
QFileDialog ,
27
29
QGroupBox ,
28
30
QHBoxLayout ,
50
52
)
51
53
52
54
55
+ Tip = namedtuple ('Tip' , ['msg' , 'pos' ])
56
+
57
+
58
+ class Tutorial (QDialog ):
59
+ def __init__ (self , parent ):
60
+ super ().__init__ (parent = parent )
61
+ self .setParent (parent )
62
+ self .setWindowTitle ("Tutorial" )
63
+ self .setModal (True )
64
+ self .setWindowOpacity (0.85 )
65
+ self .setWindowFlags (self .windowFlags () | Qt .FramelessWindowHint )
66
+
67
+ self ._current_tip = 0
68
+ self ._tips = [
69
+ Tip ('Load a folder of annotated data\n (and optionally a config file if labeling from scratch).\n Alternatively, files and folders of images can be dragged\n and dropped onto the main window.' , (0.45 , 0.05 )),
70
+ Tip ('Data layers will be listed at the bottom left;\n their visibility can be toggled by clicking on the small eye icon.' , (0.1 , 0.65 )),
71
+ Tip ('Corresponding layer controls can be found at the top left.\n Switch between labeling and selection mode using the numeric keys 2 and 3,\n or clicking on the + or -> icons.' , (0.1 , 0.2 )),
72
+ Tip ('There are three keypoint labeling modes:\n the key M can be used to cycle between them.' , (0.65 , 0.05 )),
73
+ Tip ('When done labeling, save your data by selecting the Points layer\n and hitting Ctrl+S (or File > Save Selected Layer(s)...).' , (0.1 , 0.65 )),
74
+ Tip ('''Read more at <a href='https://github.com/DeepLabCut/napari-deeplabcut#usage'>napari-deeplabcut</a>''' , (0.4 , 0.4 )),
75
+ ]
76
+
77
+ buttons = QDialogButtonBox .StandardButton .Ok | QDialogButtonBox .StandardButton .Abort
78
+ self .button_box = QDialogButtonBox (buttons )
79
+ self .button_box .accepted .connect (self .accept )
80
+ self .button_box .rejected .connect (self .reject )
81
+
82
+ vlayout = QVBoxLayout ()
83
+ self .message = QLabel ("Let's get started with a quick walkthrough!" )
84
+ self .message .setTextInteractionFlags (Qt .LinksAccessibleByMouse )
85
+ self .message .setOpenExternalLinks (True )
86
+ vlayout .addWidget (self .message )
87
+ hlayout = QHBoxLayout ()
88
+ self .count = QLabel ("" )
89
+ hlayout .addWidget (self .count )
90
+ hlayout .addWidget (self .button_box )
91
+ vlayout .addLayout (hlayout )
92
+ self .setLayout (vlayout )
93
+
94
+ def accept (self ):
95
+ if self ._current_tip == 0 and "walkthrough" not in self .message .text ():
96
+ self .reject ()
97
+ tip = self ._tips [self ._current_tip ]
98
+ self .message .setText (tip .msg )
99
+ self .count .setText (f"Tip { self ._current_tip + 1 } |{ len (self ._tips )} " )
100
+ self .adjustSize ()
101
+ xrel , yrel = tip .pos
102
+ geom = self .parent ().geometry ()
103
+ p = QPoint (
104
+ int (geom .left () + geom .width () * xrel ),
105
+ int (geom .top () + geom .height () * yrel ),
106
+ )
107
+ self .move (p )
108
+ self ._current_tip = (self ._current_tip + 1 ) % len (self ._tips )
109
+
110
+
53
111
def _get_and_try_preferred_reader (
54
112
self ,
55
113
dialog ,
@@ -163,9 +221,7 @@ def _save_layers_dialog(self, selected=False):
163
221
if not len (self .viewer .layers ):
164
222
msg = "There are no layers in the viewer to save."
165
223
elif selected and not len (selected_layers ):
166
- msg = (
167
- "Please select one or more layers to save," '\n or use "Save all layers..."'
168
- )
224
+ msg = "Please select a Points layer to save."
169
225
if msg :
170
226
QMessageBox .warning (self , "Nothing to save" , msg , QMessageBox .Ok )
171
227
return
@@ -227,7 +283,7 @@ def __init__(self, napari_viewer):
227
283
status_bar .addPermanentWidget (self .last_saved_label )
228
284
229
285
# Hack napari's Welcome overlay to show more relevant instructions
230
- overlay = self .viewer .window ._qt_viewer ._canvas_overlay
286
+ overlay = self .viewer .window ._qt_viewer ._welcome_widget
231
287
welcome_widget = overlay .layout ().itemAt (1 ).widget ()
232
288
welcome_widget .deleteLater ()
233
289
w = QtWelcomeWidget (None )
@@ -256,8 +312,23 @@ def __init__(self, napari_viewer):
256
312
self ._menus = []
257
313
258
314
self ._video_group = self ._form_video_action_menu ()
315
+ self .video_widget = self .viewer .window .add_dock_widget (
316
+ self ._video_group , name = "video" , area = "right"
317
+ )
318
+ self .video_widget .setVisible (False )
319
+
320
+ # Add some buttons to load files and data (as a complement to drag/drop)
321
+ hlayout = QHBoxLayout ()
322
+ load_config_button = QPushButton ("Load config file" )
323
+ load_config_button .clicked .connect (self ._load_config )
324
+ hlayout .addWidget (load_config_button )
325
+
326
+ self .load_data_button = QPushButton ("Load data folder" )
327
+ self .load_data_button .clicked .connect (self ._load_data_folder )
328
+ hlayout .addWidget (self .load_data_button )
329
+ self ._layout .addLayout (hlayout )
259
330
260
- vlayout = QHBoxLayout ()
331
+ hlayout = QHBoxLayout ()
261
332
trail_label = QLabel ("Show trails" )
262
333
self ._trail_cb = QCheckBox ()
263
334
self ._trail_cb .setToolTip ("toggle trails visibility" )
@@ -268,11 +339,11 @@ def __init__(self, napari_viewer):
268
339
269
340
self ._view_scheme_cb = QCheckBox ("Show color scheme" , parent = self )
270
341
271
- vlayout .addWidget (trail_label )
272
- vlayout .addWidget (self ._trail_cb )
273
- vlayout .addWidget (self ._view_scheme_cb )
342
+ hlayout .addWidget (trail_label )
343
+ hlayout .addWidget (self ._trail_cb )
344
+ hlayout .addWidget (self ._view_scheme_cb )
274
345
275
- self ._layout .addLayout (vlayout )
346
+ self ._layout .addLayout (hlayout )
276
347
277
348
self ._radio_group = self ._form_mode_radio_buttons ()
278
349
@@ -282,16 +353,54 @@ def __init__(self, napari_viewer):
282
353
self ._view_scheme_cb .toggle ()
283
354
284
355
# Substitute default menu action with custom one
285
- for action in self .viewer .window .file_menu .actions ():
286
- if "save selected layer" in action .text ().lower ():
356
+ for action in self .viewer .window .file_menu .actions ()[::- 1 ]:
357
+ action_name = action .text ().lower ()
358
+ if "save selected layer" in action_name :
287
359
action .triggered .disconnect ()
288
360
action .triggered .connect (
289
361
lambda : _save_layers_dialog (
290
362
self ,
291
363
selected = True ,
292
364
)
293
365
)
294
- break
366
+ elif "save all layers" in action_name :
367
+ self .viewer .window .file_menu .removeAction (action )
368
+
369
+ # Add action to show the walkthrough again
370
+ launch_tutorial = QAction ("&Launch Tutorial" , self )
371
+ launch_tutorial .triggered .connect (self .start_tutorial )
372
+ self .viewer .window .view_menu .addAction (launch_tutorial )
373
+
374
+ if self .settings .value ("first_launch" , True ):
375
+ QTimer .singleShot (10 , self .start_tutorial )
376
+ self .settings .setValue ("first_launch" , False )
377
+
378
+ @cached_property
379
+ def settings (self ):
380
+ return QSettings ()
381
+
382
+ def start_tutorial (self ):
383
+ Tutorial (self .viewer .window ._qt_window .__wrapped__ ).show ()
384
+
385
+ def _load_config (self ):
386
+ config = QFileDialog .getOpenFileName (
387
+ self , "Select a configuration file" , "" , "Config files (*.yaml)"
388
+ )
389
+ if not config :
390
+ return
391
+
392
+ try : # Needed to silence a late ValueError caused by the layer having no data
393
+ self .viewer .open (config , plugin = "napari-deeplabcut" , stack = False )
394
+ except ValueError :
395
+ pass
396
+
397
+ def _load_data_folder (self ):
398
+ dialog = QFileDialog (self )
399
+ dialog .setFileMode (QFileDialog .Directory )
400
+ dialog .setViewMode (QFileDialog .Detail )
401
+ if dialog .exec_ ():
402
+ folder = dialog .selectedFiles ()[0 ]
403
+ self .viewer .open (folder , plugin = "napari-deeplabcut" )
295
404
296
405
def _move_image_layer_to_bottom (self , index ):
297
406
if (ind := index ) != 0 :
@@ -317,23 +426,20 @@ def _show_trails(self, state):
317
426
colormap = "viridis" ,
318
427
)
319
428
self ._trails .visible = True
320
- else :
429
+ elif self . _trails is not None :
321
430
self ._trails .visible = False
322
431
323
432
def _form_video_action_menu (self ):
324
433
group_box = QGroupBox ("Video" )
325
434
layout = QVBoxLayout ()
326
435
extract_button = QPushButton ("Extract frame" )
327
436
extract_button .clicked .connect (self ._extract_single_frame )
328
- extract_button .setEnabled (False )
329
437
layout .addWidget (extract_button )
330
438
crop_button = QPushButton ("Store crop coordinates" )
331
439
crop_button .clicked .connect (self ._store_crop_coordinates )
332
- crop_button .setEnabled (False )
333
440
layout .addWidget (crop_button )
334
441
group_box .setLayout (layout )
335
- self ._layout .addWidget (group_box )
336
- return extract_button , crop_button
442
+ return group_box
337
443
338
444
def _extract_single_frame (self , * args ):
339
445
image_layer = None
@@ -490,8 +596,7 @@ def on_insert(self, event):
490
596
if isinstance (layer , Image ):
491
597
paths = layer .metadata .get ("paths" )
492
598
if paths is None : # Then it's a video file
493
- for widget in self ._video_group :
494
- widget .setEnabled (True )
599
+ self .video_widget .setVisible (True )
495
600
# Store the metadata and pass them on to the other layers
496
601
self ._images_meta .update (
497
602
{
@@ -570,6 +675,7 @@ def on_insert(self, event):
570
675
}
571
676
)
572
677
self ._trail_cb .setEnabled (True )
678
+ self .load_data_button .setDisabled (True )
573
679
574
680
# Hide the color pickers, as colormaps are strictly defined by users
575
681
controls = self .viewer .window .qt_viewer .dockLayerControls
@@ -578,6 +684,9 @@ def on_insert(self, event):
578
684
point_controls .edgeColorEdit .hide ()
579
685
point_controls .layout ().itemAt (9 ).widget ().hide ()
580
686
point_controls .layout ().itemAt (11 ).widget ().hide ()
687
+ # Hide out of slice checkbox
688
+ point_controls .outOfSliceCheckBox .hide ()
689
+ point_controls .layout ().itemAt (15 ).widget ().hide ()
581
690
582
691
for layer_ in self .viewer .layers :
583
692
if not isinstance (layer_ , Image ):
@@ -596,13 +705,13 @@ def on_remove(self, event):
596
705
menu .deleteLater ()
597
706
menu .destroy ()
598
707
self ._trail_cb .setEnabled (False )
708
+ self .load_data_button .setDisabled (False )
599
709
self .last_saved_label .hide ()
600
710
elif isinstance (layer , Image ):
601
711
self ._images_meta = dict ()
602
712
paths = layer .metadata .get ("paths" )
603
713
if paths is None :
604
- for widget in self ._video_group :
605
- widget .setEnabled (False )
714
+ self .video_widget .setVisible (False )
606
715
elif isinstance (layer , Tracks ):
607
716
self ._trail_cb .setChecked (False )
608
717
self ._trails = None
0 commit comments