From 10f7b5d83f84133af01f05a87a59b3d1b868e4af Mon Sep 17 00:00:00 2001 From: StableLlama Date: Mon, 10 Feb 2025 21:58:26 +0100 Subject: [PATCH] Allow zooming on center image --- taggui/widgets/image_viewer.py | 223 ++++++++++++++++++++++++++++++--- taggui/widgets/main_window.py | 57 ++++++++- 2 files changed, 261 insertions(+), 19 deletions(-) diff --git a/taggui/widgets/image_viewer.py b/taggui/widgets/image_viewer.py index 0b67d3cc..b15cc9ab 100644 --- a/taggui/widgets/image_viewer.py +++ b/taggui/widgets/image_viewer.py @@ -1,51 +1,240 @@ from pathlib import Path - -from PySide6.QtCore import QModelIndex, QSize, Qt, Slot -from PySide6.QtGui import QImageReader, QPixmap, QResizeEvent -from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget +from PySide6.QtCore import QModelIndex, QPoint, QPointF, QRect, QSize, Qt, Signal, Slot, QEvent +from PySide6.QtGui import QCursor, QImageReader, QMouseEvent, QPixmap, QResizeEvent, QWheelEvent +from PySide6.QtWidgets import (QFrame, QLabel, QScrollArea, QSizePolicy, QVBoxLayout, + QWidget) from models.proxy_image_list_model import ProxyImageListModel from utils.image import Image - class ImageLabel(QLabel): - def __init__(self): + def __init__(self, scroll_area): super().__init__() + self.scroll_area = scroll_area self.image_path = None + self.is_zoom_to_fit = True + self.zoom_factor = 1.0 + self.in_update = False self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - # This allows the label to shrink. - self.setMinimumSize(QSize(1, 1)) + self.setScaledContents(False) def resizeEvent(self, event: QResizeEvent): - """Reload the image whenever the label is resized.""" + """Resize the image whenever the label is resized.""" if self.image_path: - self.load_image(self.image_path) + self.update_image() def load_image(self, image_path: Path): self.image_path = image_path - image_reader = QImageReader(str(image_path)) + image_reader = QImageReader(str(self.image_path)) # Rotate the image according to the orientation tag. image_reader.setAutoTransform(True) - pixmap = QPixmap.fromImageReader(image_reader) - pixmap.setDevicePixelRatio(self.devicePixelRatio()) - pixmap = pixmap.scaled( - self.size() * pixmap.devicePixelRatio(), + self.pixmap_orig = QPixmap.fromImageReader(image_reader) + self.pixmap_orig.setDevicePixelRatio(self.devicePixelRatio()) + self.update_image() + + def update_image(self): + if not self.pixmap_orig or self.in_update: + return + + self.in_update = True + + if self.is_zoom_to_fit: + self.zoom_factor = self.zoom_fit_ratio() + + pixmap = self.pixmap_orig.scaled( + self.pixmap_orig.size() * self.pixmap_orig.devicePixelRatio() * self.zoom_factor, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) self.setPixmap(pixmap) + self.adjustSize() + self.in_update = False + + def zoom_in(self): + self.is_zoom_to_fit = False # No longer zoom to fit + self.zoom_factor = min(self.zoom_factor * 1.25, 4) + self.update_image() + + def zoom_out(self): + self.is_zoom_to_fit = False # No longer zoom to fit + zoom_fit_ratio = self.zoom_fit_ratio() + self.zoom_factor = max(self.zoom_factor / 1.25, min(self.zoom_fit_ratio(), 1.0)) + if self.zoom_factor == zoom_fit_ratio: + self.is_zoom_to_fit = True # At the limit? Activate fit mode again + self.update_image() + + def zoom_original(self): + self.is_zoom_to_fit = False # No longer zoom to fit + self.zoom_factor = 1.0 + self.update_image() + def zoom_fit(self): + self.is_zoom_to_fit = True + self.update_image() + + def zoom_fit_ratio(self): + widget_width = self.scroll_area.viewport().width() + widget_height = self.scroll_area.viewport().height() + image_width = self.pixmap_orig.width() + image_height = self.pixmap_orig.height() + + if image_width > 0 and image_height > 0: + width_ratio = widget_width / image_width + height_ratio = widget_height / image_height + return min(width_ratio, height_ratio) + + return 1.0 # this should not happen anyway class ImageViewer(QWidget): + zoom = Signal(float, name='zoomChanged') + def __init__(self, proxy_image_list_model: ProxyImageListModel): super().__init__() self.proxy_image_list_model = proxy_image_list_model - self.image_label = ImageLabel() - QVBoxLayout(self).addWidget(self.image_label) + self.drag_start_pos = None + self.drag_image_pos = None + + self.scroll_area = QScrollArea() + self.scroll_area.setFrameStyle(QFrame.NoFrame) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + # Install event filter on the scroll area as the wheelEvent handler + # didn't catch everything leading to strange bugs during zooming + self.scroll_area.viewport().installEventFilter(self) + layout = QVBoxLayout(self) + layout.addWidget(self.scroll_area) + self.setLayout(layout) + + + self.image_label = ImageLabel(self.scroll_area) + self.scroll_area.setWidget(self.image_label) @Slot() def load_image(self, proxy_image_index: QModelIndex): image: Image = self.proxy_image_list_model.data( proxy_image_index, Qt.ItemDataRole.UserRole) self.image_label.load_image(image.path) + self.zoom_emit() + + @Slot() + def zoom_in(self, center_pos: QPoint = None): + factors = self.get_scroll_area_factors() + self.image_label.zoom_in() + self.move_scroll_area(factors) + self.zoom_emit() + + @Slot() + def zoom_out(self, center_pos: QPoint = None): + factors = self.get_scroll_area_factors() + self.image_label.zoom_out() + self.move_scroll_area(factors) + self.zoom_emit() + + @Slot() + def zoom_original(self): + factors = self.get_scroll_area_factors() + self.image_label.zoom_original() + self.move_scroll_area(factors) + self.zoom_emit() + + @Slot() + def zoom_fit(self): + self.image_label.zoom_fit() + self.zoom_emit() + + def zoom_emit(self): + if self.image_label.is_zoom_to_fit: + self.zoom.emit(-1) + else: + self.zoom.emit(self.image_label.zoom_factor) + + def mousePressEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.MiddleButton: + # Reset zoom - and toggle between original size and fit mode + if self.image_label.is_zoom_to_fit: + self.zoom_original() + else: + self.zoom_fit() + elif event.button() == Qt.MouseButton.LeftButton: + self.drag_start_pos = event.pos() + self.drag_image_pos = (self.scroll_area.horizontalScrollBar().value(), self.scroll_area.verticalScrollBar().value()) + self.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor)) + else: + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent): + if self.drag_start_pos: + delta = event.pos() - self.drag_start_pos + self.scroll_area.horizontalScrollBar().setValue(self.drag_image_pos[0] - delta.x()) + self.scroll_area.verticalScrollBar().setValue(self.drag_image_pos[1] - delta.y()) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent): + if event.button() == Qt.MouseButton.LeftButton: + self.drag_start_pos = None + self.drag_image_pos = None + self.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) + super().mouseReleaseEvent(event) + + def eventFilter(self, source, event): + if event.type() == QEvent.Wheel: + if event.modifiers() & Qt.ControlModifier: + # Handle the control key + mouse wheel event + factors = self.get_scroll_area_factors(event.position()) + + if event.angleDelta().y() > 0: + self.zoom_in() + else: + self.zoom_out() + + self.move_scroll_area(factors) + return True # Event is handled + return super().eventFilter(source, event) + + def get_scroll_area_factors(self, position: QPointF | None = None) -> tuple[float, float, float, float]: + """ + Get the factos (fractions, percentages) of the mouse position on the + image as well as it on the scroll area. + """ + widgetPos = self.image_label.geometry() + image_size = self.image_label.pixmap_orig.size()*self.image_label.zoom_factor + if image_size.width() < self.scroll_area.viewport().width(): + offset_x = (self.scroll_area.width() - image_size.width())/2 + else: + offset_x = 0 + if image_size.height() < self.scroll_area.viewport().height(): + offset_y = (self.scroll_area.height() - image_size.height())/2 + else: + offset_y = 0 + + if position: + img_fac_x = (position.x()-widgetPos.x()-offset_x)/image_size.width() + img_fac_y = (position.y()-widgetPos.y()-offset_y)/image_size.height() + scroll_area_fac_x = position.x() / self.scroll_area.viewport().width() + scroll_area_fac_y = position.y() / self.scroll_area.viewport().height() + else: + # No position -> assume center + img_fac_x = (self.scroll_area.viewport().width()/2-widgetPos.x()-offset_x)/image_size.width() + img_fac_y = (self.scroll_area.viewport().height()/2-widgetPos.y()-offset_y)/image_size.height() + scroll_area_fac_x = 0.5 + scroll_area_fac_y = 0.5 + + return (img_fac_x, img_fac_y, scroll_area_fac_x, scroll_area_fac_y) + + def move_scroll_area(self, factors): + """ + Move the image in the scroll area so that the (fractional) position + on the image appears on the (fractional) position of the scroll area + """ + img_fac_x, img_fac_y, scroll_area_fac_x, scroll_area_fac_y = factors + image_size = self.image_label.pixmap_orig.size()*self.image_label.zoom_factor + if image_size.width() > self.scroll_area.viewport().width(): + viewport_x = scroll_area_fac_x * self.scroll_area.viewport().width() + scroll_area_x = img_fac_x * image_size.width() + self.scroll_area.horizontalScrollBar().setValue(scroll_area_x - viewport_x) + if image_size.height() > self.scroll_area.viewport().height(): + viewport_y = scroll_area_fac_y * self.scroll_area.viewport().height() + scroll_area_y = img_fac_y * image_size.height() + self.scroll_area.verticalScrollBar().setValue(scroll_area_y - viewport_y) diff --git a/taggui/widgets/main_window.py b/taggui/widgets/main_window.py index 78a8cf98..6b780a47 100644 --- a/taggui/widgets/main_window.py +++ b/taggui/widgets/main_window.py @@ -4,8 +4,8 @@ from PySide6.QtGui import (QAction, QCloseEvent, QDesktopServices, QIcon, QKeySequence, QPixmap, QShortcut) from PySide6.QtWidgets import (QApplication, QFileDialog, QMainWindow, - QMessageBox, QStackedWidget, QVBoxLayout, - QWidget) + QMessageBox, QStackedWidget, QToolBar, + QVBoxLayout, QWidget) from transformers import AutoTokenizer from dialogs.batch_reorder_tags_dialog import BatchReorderTagsDialog @@ -62,7 +62,29 @@ def __init__(self, app: QApplication): # everything has the correct font size. self.set_font_size() self.image_viewer = ImageViewer(self.proxy_image_list_model) + self.image_viewer.zoom.connect(self.zoom) self.create_central_widget() + + self.toolbar = QToolBar('Main toolbar', self) + self.toolbar.setObjectName('Main toolbar') + self.toolbar.setFloatable(True) + self.addToolBar(self.toolbar) + self.zoom_fit_best_action = QAction(QIcon.fromTheme('zoom-fit-best'), 'Zoom to fit', self) + self.zoom_fit_best_action.setCheckable(True) + #self.zoom_fit_best_action.triggered.connect(self.image_viewer.zoom_fit) + self.toolbar.addAction(self.zoom_fit_best_action) + self.zoom_in_action = QAction(QIcon.fromTheme('zoom-in'), 'Zoom in', self) + #self.zoom_in_action.triggered.connect(self.image_viewer.zoom_in) + self.toolbar.addAction(self.zoom_in_action) + self.zoom_original_action = QAction(QIcon.fromTheme('zoom-original'), 'Original size', self) + self.zoom_original_action.setCheckable(True) + #self.zoom_original_action.triggered.connect(self.image_viewer.zoom_original) + self.toolbar.addAction(self.zoom_original_action) + self.zoom_out_action = QAction(QIcon.fromTheme('zoom-out'), 'Zoom out', self) + #self.zoom_out_action.triggered.connect(self.image_viewer.zoom_out) + self.toolbar.addAction(self.zoom_out_action) + self.toolbar.addSeparator() + self.image_list = ImageList(self.proxy_image_list_model, tag_separator, image_list_image_width) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, @@ -101,6 +123,7 @@ def __init__(self, app: QApplication): self.reload_directory_action.setDisabled(True) self.undo_action = QAction('Undo', parent=self) self.redo_action = QAction('Redo', parent=self) + self.toggle_toolbar_action = QAction('Toolbar', parent=self) self.toggle_image_list_action = QAction('Images', parent=self) self.toggle_image_tags_editor_action = QAction('Image Tags', parent=self) @@ -113,6 +136,7 @@ def __init__(self, app: QApplication): .selectionModel()) self.image_list_model.image_list_selection_model = ( self.image_list_selection_model) + self.connect_toolbar_signals() self.connect_image_list_signals() self.connect_image_tags_editor_signals() self.connect_all_tags_editor_signals() @@ -206,6 +230,18 @@ def create_central_widget(self): central_widget.addWidget(self.image_viewer) self.setCentralWidget(central_widget) + @Slot() + def zoom(self, factor): + if factor < 0: + self.zoom_fit_best_action.setChecked(True) + self.zoom_original_action.setChecked(False) + elif factor == 1.0: + self.zoom_fit_best_action.setChecked(False) + self.zoom_original_action.setChecked(True) + else: + self.zoom_fit_best_action.setChecked(False) + self.zoom_original_action.setChecked(False) + def load_directory(self, path: Path, select_index: int = 0, save_path_to_settings: bool = False): self.directory_path = path.resolve() @@ -355,10 +391,13 @@ def create_menus(self): edit_menu.addAction(remove_empty_tags_action) view_menu = menu_bar.addMenu('View') + self.toggle_toolbar_action.setCheckable(True) self.toggle_image_list_action.setCheckable(True) self.toggle_image_tags_editor_action.setCheckable(True) self.toggle_all_tags_editor_action.setCheckable(True) self.toggle_auto_captioner_action.setCheckable(True) + self.toggle_toolbar_action.triggered.connect( + lambda is_checked: self.toolbar.setVisible(is_checked)) self.toggle_image_list_action.triggered.connect( lambda is_checked: self.image_list.setVisible(is_checked)) self.toggle_image_tags_editor_action.triggered.connect( @@ -367,6 +406,7 @@ def create_menus(self): lambda is_checked: self.all_tags_editor.setVisible(is_checked)) self.toggle_auto_captioner_action.triggered.connect( lambda is_checked: self.auto_captioner.setVisible(is_checked)) + view_menu.addAction(self.toggle_toolbar_action) view_menu.addAction(self.toggle_image_list_action) view_menu.addAction(self.toggle_image_tags_editor_action) view_menu.addAction(self.toggle_all_tags_editor_action) @@ -425,6 +465,19 @@ def save_image_index(self, proxy_image_index: QModelIndex): else 'filtered_image_index') self.settings.setValue(settings_key, proxy_image_index.row()) + def connect_toolbar_signals(self): + self.zoom_fit_best_action.triggered.connect( + self.image_viewer.zoom_fit) + self.zoom_in_action.triggered.connect( + self.image_viewer.zoom_in) + self.zoom_original_action.triggered.connect( + self.image_viewer.zoom_original) + self.zoom_out_action.triggered.connect( + self.image_viewer.zoom_out) + self.toolbar.visibilityChanged.connect( + lambda: self.toggle_toolbar_action.setChecked( + self.toolbar.isVisible())) + def connect_image_list_signals(self): self.image_list.filter_line_edit.textChanged.connect( self.set_image_list_filter)