class ProgramWidget(QWidget): def __init__(self, parent): super().__init__(parent) self.image_widget = ImageWidget(parent) self.hist_widget = HistogramWidget(parent) self.coord_label = QLabel("", self) self.pixel_rgb_label = QLabel('', self) self.pixel_hsv_label = QLabel('', self) self.pixel_lab_label = QLabel('', self) self.hsv_checkbox = QCheckBox("Shift HSV") self.hsv_checkbox.toggled.connect(self.slider_update) h_slider_box, self.h_slider = self._get_slider_box( "H:", -180, 180, 60, self.slider_update ) s_slider_box, self.s_slider = self._get_slider_box( "S:", -100, 100, 10, self.slider_update ) v_slider_box, self.v_slider = self._get_slider_box( "V:", -100, 100, 10, self.slider_update ) self._set_default() self.image_widget.selection_update.connect(self.selection_upd) hbox = QHBoxLayout() hbox.addWidget(self.image_widget, 20) vbox = QVBoxLayout() vbox.addWidget(self.hist_widget) vbox.addWidget(self.coord_label) vbox.addWidget(self.pixel_rgb_label) vbox.addWidget(self.pixel_hsv_label) vbox.addWidget(self.pixel_lab_label) vbox.addWidget(self.hsv_checkbox) vbox.addLayout(h_slider_box) vbox.addLayout(s_slider_box) vbox.addLayout(v_slider_box) self._filter_buttons(vbox) hbox.addLayout(vbox, 1) self.setLayout(hbox) def _filter_buttons(self, vbox): self.filter_rbtn = QButtonGroup() hbox = QHBoxLayout() radio1 = QRadioButton("Отключить", self) self.filter_rbtn.addButton(radio1, 0) hbox.addWidget(radio1) radio1.setChecked(True) radio2 = QRadioButton("Гаусса", self) self.filter_rbtn.addButton(radio2, 1) hbox.addWidget(radio2) radio3 = QRadioButton("Собеля", self) self.filter_rbtn.addButton(radio3, 2) hbox.addWidget(radio3) radio4 = QRadioButton("Габора", self) self.filter_rbtn.addButton(radio4, 3) hbox.addWidget(radio4) vbox.addLayout(hbox) slider_box, self.sigma_slider = self._get_slider_box("σ", 0, 10, 0.5, self._filter_change) vbox.addLayout(slider_box) self.filter_rbtn.buttonClicked.connect(self._filter_change) def _filter_change(self): filter_id = self.filter_rbtn.checkedId() self._update_sigma_slider(filter_id) param1 = self.sigma_slider.value() / 10 self.image_widget.set_filter(filter_id, param1) def _update_sigma_slider(self, filter_id): if filter_id == 1: self.sigma_slider.setEnabled(True) self.sigma_slider.setText("σ") self.sigma_slider.setMinimum(0) self.sigma_slider.setMaximum(100) self.sigma_slider.setTickInterval(1) elif filter_id == 3: self.sigma_slider.setEnabled(True) self.sigma_slider.setText("θ") # self.sigma_slider.setValue(1) self.sigma_slider.setMinimum(0) self.sigma_slider.setMaximum(3600) self.sigma_slider.setTickInterval(50) else: self.sigma_slider.setText("-") self.sigma_slider.setDisabled(True) def _get_slider_box(self, label_name, _min, _max, _interval, callback, layout=Qt.Horizontal) -> Tuple[QBoxLayout, QSlider]: slider = QSlider(layout, self) slider.setMinimum(_min) slider.setMaximum(_max) slider.setTickPosition(QSlider.TicksBelow) slider.setTickInterval(_interval) slider.sliderReleased.connect(callback) box = QHBoxLayout() label = QLabel(label_name, self) box.addWidget(label) box.addWidget(slider) slider._label = label def setText(text): slider._label.setText(text) slider.setText = setText return box, slider def _set_default(self): self.coord_label.setText("Select pixel") self.pixel_rgb_label.setText("Select pixel") self.pixel_hsv_label.setText("Select pixel") self.pixel_lab_label.setText("Select pixel") self.h_slider.setValue(0) def slider_update(self): if self.hsv_checkbox.isChecked(): self.image_widget.shift_hsv = self.h_slider.value(), self.s_slider.value(), self.v_slider.value() else: self.image_widget.shift_hsv = [0, 0, 0] def selection_upd(self): img = self.image_widget.selected(self.hist_widget.speed) print(img.width(), img.height()) coord = self.image_widget.selection_img self.coord_label.setText("{}, {} x {}, {}".format( coord.left(), coord.top(), coord.right(), coord.bottom() )) if 1 == img.width() == img.height(): pixel = QColor(img.pixel(0, 0)) self.pixel_rgb_label.setText( "R:{}, G:{}, B:{}".format(pixel.red(), pixel.green(), pixel.blue()) ) self.pixel_hsv_label.setText( "H:{}, S:{}, V:{}".format(pixel.hue(), pixel.saturation(), pixel.value()) ) self.pixel_lab_label.setText( "L:{:.1f}, A:{:.1f}, B:{:.1f}".format(*pixel.lab()) ) else: self.pixel_rgb_label.setText("Select one pixel") self.pixel_hsv_label.setText("Select one pixel") self.pixel_lab_label.setText("Select one pixel") self.hist_widget.calc_image(img) def set_image(self, image: QImage): self.image_widget.set_image(image)
class View(Subject, QMainWindow): """Main window class, and the View of the MVC pattern. Attributes: model: The model of the MVC pattern. menu_bar: A menu bar derived from QMenuBar. tool_bar: A tool bar derived from QToolBar. exif_area: The widget containing any available EXIF data. image_area: The widget containing the displayed image. status_bar: A status bar derived from QStatusBar. about: The classic "About" informative widget (actually a new, separate window). """ def __init__(self, model): """Inits the class.""" Subject.__init__(self) QMainWindow.__init__(self) # Set model self.model = model # Create interface elements self.menu_bar = MenuBar(self) self.tool_bar = ToolBar(self) self.exif_area = ExifWidget(self) self.image_area = ImageWidget(self) self.status_bar = StatusBar() about_text = 'IEViewer 1.0' \ '<br><br>' \ 'Copyright © 2021 by' \ '<br>' \ 'Paula Mihalcea' \ '<br>' \ '<a href="mailto:[email protected]">[email protected]</a>' \ '<br><br>' \ 'This program uses PyQt5, a comprehensive set of Python bindings for Qt v5. Qt is a set of cross-platform C++ libraries that implement high-level APIs for accessing many aspects of modern desktop and mobile systems.\n' \ '<br><br>' \ 'PyQt5 is copyright © Riverbank Computing Limited. Its homepage is <a href="https://www.riverbankcomputing.com/software/pyqt/">https://www.riverbankcomputing.com/software/pyqt/</a>.' \ '<br><br>' \ 'No genasi were harmed in the making of this application. <a href="https://www.dndbeyond.com/races/genasi#WaterGenasi">#GenasiLivesMatter#NereisThalian</a>' self.about = AboutWidget('About IEViewer', about_text, image_path='icons/about_img.png') # Disable GUI elements that are unavailable when no image is opened self.menu_bar.disable_widgets() self.exif_area.hide() # Set layout self.setCentralWidget(Layout(self).central_widget) # Set window properties self.set_window_properties() # Install additional event filters self.image_area.installEventFilter(self) def set_window_properties(self): """Sets some main window properties.""" self.statusBar() self.setStatusTip('Ready.') self.setWindowTitle( 'IEViewer') # Window title (the one in the title bar) self.resize( 512, 256) # These default dimensions should be fine for most displays def get_open_file_dialog(self, caption, filter): """Opens an "Open File" dialog and returns a file path. If no file has been selected and the user has pressed "Cancel" or closed the dialog, then it returns None.""" file_path, _ = QFileDialog.getOpenFileName(caption=caption, directory='', filter=filter) if file_path == '': return None else: return file_path def get_save_file_dialog(self, caption, filter): """Opens a "Save As" dialog and returns a file path and format. If no name has been entered and the user has pressed "Cancel" or closed the dialog, then it returns None.""" file_path, format = QFileDialog.getSaveFileName( caption=caption, directory='', filter=filter ) # By omitting the directory argument (empty string, ''), the dialog should remember the last directory (depends on operating system) if file_path == '': return None, None else: format = format.split(' ') return file_path, format[0] def show_message_box(self, title, text, icon=QMessageBox.Information): """Opens a simple message box window, with some text and a customizable icon.""" info_box = QMessageBox(self) if icon is not None: info_box.setIcon(icon) info_box.setWindowTitle(title) info_box.setText(text) info_box.show() def event(self, e): """Defines the default status bar message (when nothing else is displayed).""" if e.type() == QEvent.StatusTip: if e.tip() == '': e = QStatusTipEvent('Ready.') return super().event(e) def open(self): """Only needed to notify observers (i.e. the controller) that they should load an image.""" self.set_state('open') def load_image(self): """Displays the image contained in the model.""" # Get original image dimensions width = self.model.image.width() height = self.model.image.height() # Recalculate image dimensions so as to have a maximum dimension (height or width) of 512 pixels if width >= height and width > 512: w = 512 h = int(512 * height / width) elif height >= width and height > 512: w = int(512 * width / height) h = 512 else: w = width h = height # Generate and set a pixmap from the image self.image_area.set_image(self.model.image, w, h) # Resize the window and pixmap (generated from the image), # so as to display correctly the window title, menu bar and image (actually, its pixmap) # # The diff parameter is another piece of PyQt5 magic. # Without it, an image larger than 512 pixels might be displayed smaller # than the maximum 512 that it has been resized to # (despite having been resized to 512 pixels). diff = 130 # This value is good for both Windows 10 and Ubuntu 20.04 if w < 280: # Again, this should suffice for both OSs self.resize(280, h + diff) else: self.resize(w, h + diff) self.image_area.pixmap.scaled(w, h) # Add EXIF data from the model self.exif_area.load_exif() # Update window title self.setWindowTitle('IEViewer - ' + self.model.filename) # Enable menus self.menu_bar.enable_widgets() def save(self): """Saves the modified image. Uses the stored original image, not the modified version currently displayed in the window.""" self.model.set_image( self.model.image.transformed( QTransform().rotate(self.image_area.rot), Qt.SmoothTransformation)) self.set_state('save') def saveas(self): """Saves the modified image with another name. Uses the stored original image, not the modified version currently displayed in the window.""" self.model.set_image( self.model.image.transformed( QTransform().rotate(self.image_area.rot), Qt.SmoothTransformation)) self.set_state('saveas') def eventFilter(self, widget, event): """Adds new behavior for certain events.""" # Resize event # Or what should happen to the image widget when the user resizes the main window if event.type( ) == QEvent.Resize and widget is self.image_area and self.image_area.pixmap is not None: self.image_area.setPixmap( QPixmap.fromImage(self.model.modified_image).scaled( self.image_area.width(), self.image_area.height(), aspectRatioMode=Qt.KeepAspectRatio, transformMode=Qt.SmoothTransformation)) # Update new dimensions for later use self.image_area.w = self.image_area.width() self.image_area.h = self.image_area.height() return True return QMainWindow.eventFilter(self, widget, event) def close(self): """Closes an image.""" self.set_state('close') # Clear image area widget and adjust layout self.image_area.clear_image() self.exif_area = ExifWidget(self) self.setCentralWidget(Layout(self).central_widget) # Disable unavailable menus self.menu_bar.disable_widgets() # Hide EXIF area (if visible) and show blank image widget self.exif_area.hide() self.image_area.show() # Update window title self.setWindowTitle('IEViewer') def exit(self): """Only needed to notify observers (i.e. the controller) that they should exit the application.""" self.set_state('exit') def rotate(self, degree): """Base function for rotation. Subsequent rotation functions are only needed as hooks for menu actions; actual rotations happen here. Here, the View directly changes the Model. Arguably, this is a violation of the MVC pattern. It happens that, for some reason, returning a reference to the rotated image to the Controller in order to have the Controller itself change the Model does not work, probably because it only returns a reference and not a copy of the rotated image. PyQt5 does not allow deep copies of its objects, so the following seems to be the only way to make it work. """ self.model.set_modified_image( self.model.image.transformed( QTransform().rotate(self.image_area.rot + degree), Qt.SmoothTransformation)) # Rotate image self.image_area.set_image(self.model.modified_image, self.image_area.w, self.image_area.h) # Display rotated image self.image_area.rot += degree # Update rotation history def rotate180(self): """Rotate the displayed image by 180 degrees.""" self.rotate(180) def rotate90c(self): """Rotate the displayed image by 90 degrees clock wise.""" self.rotate(90) def rotate90cc(self): """Rotate the displayed image by 90 degrees clock wise.""" self.rotate(-90) def reset_image(self): """Reset the displayed image to its original orientation.""" self.rotate(-self.image_area.rot) def show_exif(self): """Display EXIF data (and at the same time hide the image).""" if self.exif_area.isHidden(): self.image_area.hide() self.exif_area.show() else: self.image_area.show() self.exif_area.hide() def about(self): """Display info about the program.""" self.about.show()