Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow zooming on center image #335

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 206 additions & 17 deletions taggui/widgets/image_viewer.py
Original file line number Diff line number Diff line change
@@ -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)
57 changes: 55 additions & 2 deletions taggui/widgets/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down