Source code for secv_guis.base_widgets
# -*- coding:utf-8 -*-
"""
This module is a library of reusable, extendable widgets.
"""
import os
from PySide2 import QtCore, QtWidgets
# #############################################################################
# # BASIC WIDGETS
# #############################################################################
[docs]class FileList(QtWidgets.QWidget):
"""
A file dialog button followed by a list that shows the files in the
selected folder.
"""
def __init__(self, label, parent=None,
default_path=None, extensions=None, sort=True):
"""
:param extensions: A list of string terminations to match, or ``None``
for match-all.
:param default_path: If None, 'home' is picked as default.
:param sort: If true, contents will always be shown sorted
"""
super().__init__(parent)
self.sort = sort
self.dirpath = (os.path.expanduser("~") if default_path is None
else default_path)
self.extensions = [""] if extensions is None else extensions
self.label = label
# create widgets
self.file_button = QtWidgets.QPushButton(self.label)
self.file_list = QtWidgets.QListWidget()
# add widgets to layout
self.main_layout = QtWidgets.QVBoxLayout(self)
self.main_layout.addWidget(self.file_button)
self.main_layout.addWidget(self.file_list)
# connect
self.file_button.pressed.connect(self._file_dialog_handler)
# track filesystem changes
self.file_watcher = QtCore.QFileSystemWatcher()
self.file_watcher.fileChanged.connect(
lambda: self.update_path(self.dirpath))
self.file_watcher.directoryChanged.connect(
lambda: self.update_path(self.dirpath))
[docs] def update_path(self, dirname,selected_image=None):
"""
:param str dirname: The new directory path to be listed.
:param selected_images: The name of an image with which the list can be filtered
"""
if selected_image is None:
file_names = [f for f in os.listdir(dirname)
if f.lower().endswith(tuple(self.extensions))]
else:
file_names = [f for f in os.listdir(dirname)
if f.lower().endswith(tuple(self.extensions))
and selected_image.lower() in f.lower()]
self.file_list.clear()
self.file_list.addItems(file_names)
#
self.file_watcher.removePath(self.dirpath)
self.file_watcher.addPath(dirname)
#
self.dirpath = dirname
if self.sort:
self.file_list.sortItems(QtCore.Qt.AscendingOrder)
def _file_dialog_handler(self):
"""
Opens a file dialog which returns the selected path.
"""
dirname = QtWidgets.QFileDialog.getExistingDirectory(
self, self.label, self.dirpath)
if dirname:
self.update_path(dirname)
[docs]class CheckBoxGroup(QtWidgets.QWidget):
"""
A group of ``CheckBox`` es
"""
def __init__(self, parent=None, horizontal=False):
"""
"""
super().__init__(parent=parent)
ly = QtWidgets.QHBoxLayout() if horizontal else QtWidgets.QVBoxLayout()
self.setLayout(ly)
[docs] def add_box(self, name, tristate=False, initial_val=True):
"""
:param bool tristate: If true, the added check box will have 3 states.
"""
b = QtWidgets.QCheckBox(name)
b.setTristate(tristate)
b.setChecked(initial_val)
self.layout().addWidget(b)
[docs] def remove_box(self, idx):
"""
:param int idx: Boxes are added in increasing index order, so this
the lower this index the 'older' the box that is being removed.
"""
b = self.layout().takeAt(idx)
b.widget().setParent(QtWidgets.QWidget()) # this is needed...
[docs] def state(self):
"""
:returns: A list with all the current states, in index order.
"""
ly = self.layout()
return [ly.itemAt(i).widget().checkState() for i in range(ly.count())]
# #############################################################################
# # PAINTING SECTION
# #############################################################################
[docs]class RGBASpinbox(QtWidgets.QWidget):
"""
A cluster of 4 [0-255] spin boxes, representing (and having) an RGBA
color. Use ``self.connect`` to wire this widget to any method.
"""
def __init__(self, initial_rgba=(0, 255, 0, 100), parent=None,
min_alpha=1, max_alpha=255):
"""
"""
super().__init__(parent)
self.main_layout = QtWidgets.QHBoxLayout(self)
# create widgets
self.label = QtWidgets.QLabel("RGBA:")
r, g, b, a = initial_rgba
self.r = self._make_box(initial=r)
self.g = self._make_box(initial=g)
self.b = self._make_box(initial=b)
self.a = self._make_box(a, min_alpha, max_alpha)
# add widgets to layout
self.main_layout.addWidget(self.label)
self.main_layout.addWidget(self.r)
self.main_layout.addWidget(self.g)
self.main_layout.addWidget(self.b)
self.main_layout.addWidget(self.a)
# connect widgets to color listener
self.r.valueChanged.connect(
lambda: self._set_currentcolor_stylesheet())
self.g.valueChanged.connect(
lambda: self._set_currentcolor_stylesheet())
self.b.valueChanged.connect(
lambda: self._set_currentcolor_stylesheet())
self.a.valueChanged.connect(
lambda: self._set_currentcolor_stylesheet())
#
self._set_currentcolor_stylesheet()
@staticmethod
def _make_box(initial=100, minimum=0, maximum=255, step=8):
"""
"""
b = QtWidgets.QSpinBox()
b.setRange(minimum, maximum)
b.setSingleStep(step)
b.setValue(initial)
return b
[docs] def get_current_rgba(self):
"""
:returns: Current state as ``(r, g, b, a)`` numeric tuple.
"""
r = self.r.value()
g = self.g.value()
b = self.b.value()
a = self.a.value()
return(r, g, b, a)
def _set_currentcolor_stylesheet(self):
"""
"""
r, g, b, _ = self.get_current_rgba()
ssheet = "QSpinBox {color: rgb(%d, %d, %d)}" % (r, g, b)
self.r.setStyleSheet(ssheet)
self.g.setStyleSheet(ssheet)
self.b.setStyleSheet(ssheet)
self.a.setStyleSheet(ssheet)
[docs] def connect(self, fn):
"""
:param fn: A function to connect this widget to. It must have the
following signature``fn(idx, r, g, b, a)``.
When calling ``self.connect(f)``, any value changes in R, G, B or
A will trigger ``f(r, g, b, a)`` with the changed values
"""
# NOTE THAT LAMBDAS WITH SELF INSIDE WILL PREVENT THE WIDGET
# TO BE GARBAGE-COLLECTED: https://stackoverflow.com/a/48501804/4511978
# THIS IS BAD FOR WIDGETS THAT GET INSTANTIATED MANY TIMES
self.r.valueChanged.connect(lambda: fn(*self.get_current_rgba()))
self.g.valueChanged.connect(lambda: fn(*self.get_current_rgba()))
self.b.valueChanged.connect(lambda: fn(*self.get_current_rgba()))
self.a.valueChanged.connect(lambda: fn(*self.get_current_rgba()))
[docs]class MaskPaintForm(QtWidgets.QWidget):
"""
This widget contains one section for the masks and one for the painter.
The mask section contains a set of elements, one per mask. A radio button
selects the currently active mask, and each mask features an RGBA box
and a thershold slider. The painter section contains a ComboBox to select
the painter type, and a slider for the painter size.
To use it in specific applications override ``button_pressed,
combo_box_changed, rgba_box_changed...``
"""
def __init__(self, brush_names, max_brush_size=100, parent=None,
thresh_min=0, thresh_max=1, thresh_num_steps=100,
min_alpha=1, max_alpha=255):
"""
"""
super().__init__(parent)
# self.masks = masks
self.brush_names = brush_names
self.max_brush_size = max_brush_size
self.thresh_min = thresh_min
self.thresh_max = thresh_max
self.thresh_num_steps = thresh_num_steps
self.min_alpha = min_alpha
self.max_alpha = max_alpha
#
self._buttons = []
self._boxes = []
self._labels = []
self._sliders = []
#
self.paint_button_group = QtWidgets.QButtonGroup(self)
self._define_layout()
# add connections
# NOTE THAT LAMBDAS WITH SELF INSIDE WILL PREVENT THE WIDGET
# TO BE GARBAGE-COLLECTED: https://stackoverflow.com/a/48501804/4511978
# THIS IS BAD FOR WIDGETS THAT GET INSTANTIATED MANY TIMES
self.paint_button_group.buttonClicked.connect(
lambda _: self.button_pressed(
self.paint_button_group.checkedButton()))
self.brush_combo_box.currentIndexChanged.connect(
self.brush_type_changed)
self.brush_size_slider.valueChanged.connect(self._update_brush_size)
def _define_layout(self):
"""
"""
# make brush selector and size widgets
self.brush_combo_box = QtWidgets.QComboBox()
self.brush_combo_box.addItems(self.brush_names)
self.brush_size_label = QtWidgets.QLabel()
self.brush_size_slider = QtWidgets.QSlider(None) # vertical
self.brush_size_slider.setMinimum(1)
self.brush_size_slider.setMaximum(self.max_brush_size)
self.brush_size_slider.setValue(self.max_brush_size // 2)
self.brush_size_slider.setSingleStep(1)
self._set_brush_size_label(self.brush_size_slider.value())
# Layout hierarchy:
main_layout = QtWidgets.QHBoxLayout()
# this is modified with add/remove
self.masks_layout = QtWidgets.QVBoxLayout()
# make sub-layout with [brush_type; brush_size]
brush_layout = QtWidgets.QVBoxLayout()
brush_layout.addWidget(self.brush_combo_box)
brush_layout.addWidget(self.brush_size_label)
brush_layout.addWidget(self.brush_size_slider)
brush_layout.setAlignment(QtCore.Qt.AlignRight)
# connect layout hierarchy
main_layout.addLayout(self.masks_layout)
main_layout.addLayout(brush_layout)
self.setLayout(main_layout)
[docs] def add_item(self, name, rgba, slider_visible=True, activate=False):
"""
Add an element to the 'mask' section, with the given name and color.
:param slider_visible: If false, the slider will be still there but
hidden.
:param activate: Once created, select this item in the radio buttons.
"""
# sub-layout with 2-row elts: [button, colordialog; label, threshold]
but = QtWidgets.QRadioButton(name)
box = RGBASpinbox(rgba, None, self.min_alpha, self.max_alpha)
sl = QtWidgets.QSlider(None, orientation=QtCore.Qt.Horizontal)
lbl = QtWidgets.QLabel()
sl.setVisible(slider_visible)
lbl.setVisible(slider_visible)
sl.setMinimum(0)
sl.setMaximum(self.thresh_num_steps)
sl.setSingleStep(1)
sl.setValue(sl.maximum())
#
sl2 = QtWidgets.QSlider(None, orientation=QtCore.Qt.Horizontal)
lbl2 = QtWidgets.QLabel()
sl2.setVisible(slider_visible)
lbl2.setVisible(slider_visible)
sl2.setMinimum(0)
sl2.setMaximum(self.thresh_num_steps)
sl2.setSingleStep(1)
sl2.setValue(sl2.maximum() * 0.9)
#
self._buttons.append(but)
self._boxes.append(box)
self._labels.append(lbl)
self._labels.append(lbl2)
self._sliders.append(sl)
self._sliders.append(sl2)
# local layout hierarchy
lyt = QtWidgets.QVBoxLayout()
top = QtWidgets.QHBoxLayout()
bottom = QtWidgets.QVBoxLayout()
bottom_label_field= QtWidgets.QHBoxLayout()
top.addWidget(but)
top.addWidget(box)
bottom_label_field.addWidget(lbl)
bottom_label_field.addWidget(lbl2)
bottom.addLayout(bottom_label_field)
bottom.addWidget(sl)
bottom.addWidget(sl2)
lyt.addLayout(top)
lyt.addLayout(bottom)
# add local hierarchy to main layout and button to group
self.masks_layout.addLayout(lyt)
self.paint_button_group.addButton(but)
# add connections
box.connect(
lambda r, g, b, a: self._handle_rgba_box_changed(box, r, g, b, a))
sl.sliderReleased.connect(
lambda: self._handle_threshold_slider_changed(sl,sl2))
sl2.sliderReleased.connect(
lambda: self._handle_threshold_slider_changed(sl,sl2))
sl.valueChanged.connect(
lambda val: self._set_thresh_label(lbl,val,"Upper thresh"))
sl2.valueChanged.connect(
lambda val: self._set_thresh_label(lbl2,val,"Lower thresh"))
# initialize label
self._set_thresh_label(lbl, sl.value(),"Upper thresh")
self._set_thresh_label(lbl2, sl2.value(),"Lower thresh")
#
if activate:
but.click()
[docs] def remove_item(self, idx):
"""
Remove an element from the 'mask' section by index. Indexes are in
increasing order, so lowest is oldest.
"""
# remove from self placeholders
but = self._buttons.pop(idx)
box = self._boxes.pop(idx)
lbl = self._labels.pop(idx)
sl = self._sliders.pop(idx)
lyt = self.masks_layout.takeAt(idx)
# reassign dummy parent to remove from QT placeholders
w = QtWidgets.QWidget() # this is needed...
but.setParent(w)
box.setParent(w)
lbl.setParent(w)
sl.setParent(w)
lyt.setParent(w)
[docs] def slider_to_p_val(self, sl_val):
"""
Since the slider goes from 0 to ``thresh_num_steps``, this function
linearly interpolates the, so that 0 maps to ``thresh_min`` and
``thresh_num_steps`` maps to ``thresh_max``. Note that min does not
neccesarily have to be smaller than max.
:param int sl_val: The actual slider value from 0 to num_steps.
:returns: The converted and interpolated value.
"""
delta = float(sl_val) / self.thresh_num_steps
pval = self.thresh_min + delta * (self.thresh_max - self.thresh_min)
return pval
def _set_thresh_label(self, lbl, sl_val,name):
"""
"""
t = self.slider_to_p_val(sl_val)
lbl.setText(name+": {:0.0f}".format(t))
return t
def _set_brush_size_label(self, sl_val):
"""
"""
self.brush_size_label.setText("Brush size: {}".format(sl_val))
def _handle_threshold_slider_changed(self, sl,sl2):
"""
"""
self.threshold_slider_changed(sl.value(),sl2.value())
def _handle_rgba_box_changed(self, box, r, g, b, a):
"""
"""
idx = self._boxes.index(box)
self.rgba_box_changed(idx, r, g, b, a)
def _update_brush_size(self, sl_val):
"""
"""
self._set_brush_size_label(sl_val)
self.brush_size_changed(sl_val)
[docs] def button_pressed(self, but):
"""
Override me!
:param int idx: Starts with 0 and respects ordering given at
construction. So when overriding this method, you can assume
that 0 will correspond to the firstly added element, and
so on. Implementation example::
i = self._buttons.index(but)
print("button pressed: >>>", i, but.text())
"""
pass
[docs] def brush_type_changed(self, idx):
"""
Override me!
:param int idx: Starts with 0 and respects ordering given at
construction. So when overriding this method, you can assume
that 0 will correspond to the firstly added element, and
so on.
"""
pass
# #############################################################################
# # SAVING SECTON
# #############################################################################
[docs]class SaveForm(QtWidgets.QWidget):
"""
A formulary providing functionality for selecting what to save, where to
save, the output suffix and overwriting policy.
"""
SAVE_TEXT = "Save\nselected"
DIALOG_TEXT = "Output\nfolder"
OVERWRITE_TEXT = "Overwrite\nsaved"
def __init__(self, parent=None, default_path=None):
"""
:param str default_path: If not given, 'home' is picked as default.
"""
super().__init__(parent)
self.save_path = (os.path.expanduser("~") if default_path is None
else default_path)
# create widgets
self.save_group = CheckBoxGroup()
self.text_boxes = QtWidgets.QVBoxLayout()
self.file_dialog_button = QtWidgets.QPushButton(self.DIALOG_TEXT)
self.save_button = QtWidgets.QPushButton(self.SAVE_TEXT)
self.overwrite_button = QtWidgets.QCheckBox(self.OVERWRITE_TEXT)
self.overwrite_button.setChecked(True)
# build layout hierarchy
self.main_layout = QtWidgets.QHBoxLayout()
self.main_layout.addWidget(self.save_group)
self.main_layout.addLayout(self.text_boxes)
#
lyt = QtWidgets.QVBoxLayout()
lyt.addWidget(self.file_dialog_button)
lyt.addWidget(self.save_button)
lyt.addWidget(self.overwrite_button)
self.main_layout.addLayout(lyt)
self.setLayout(self.main_layout)
# add connections
self.file_dialog_button.clicked.connect(self._change_save_path)
self.save_button.clicked.connect(self._handle_save_masks)
[docs] def add_checkbox(self, checkbox_name, initial_val=True, initial_txt=None):
"""
Adds an element that can be selected to be saved.
:param checkbox_name: The element identifier
:param initial_txt: The initial suffix to be appended to the files. If
none is given, the ``checkbox_name`` is picked as default. The user
can change this from the GUI.
"""
if initial_txt is None:
initial_txt = checkbox_name
tb = QtWidgets.QLineEdit(initial_txt)
#
self.save_group.add_box(checkbox_name, False, initial_val)
self.text_boxes.addWidget(tb)
def _change_save_path(self):
"""
"""
dir_name = QtWidgets.QFileDialog.getExistingDirectory(
self, self.DIALOG_TEXT, self.save_path)
if dir_name:
self.save_path = dir_name
def _handle_save_masks(self):
"""
"""
states = [s == QtCore.Qt.CheckState.Checked
for s in self.save_group.state()]
suffixes = [self.text_boxes.itemAt(i).widget().text()
for i in range(self.text_boxes.count())]
overwrite = self.overwrite_button.isChecked()
self.save_masks(states, suffixes, overwrite)
[docs] def save_masks(self, states, suffixes, overwrite):
"""
:param states: A list with booleans, representing the checkbox
states for the contained elements.
:param suffixes: A list with the corresponding suffixes
:param overwrite: A boolean determining whether the 'overwrite'
checkbox has been activated.
Override me!
"""
print("check boxes:", states, "suffixes:", suffixes,
"out path:", self.save_path, "overwrite:", overwrite)