Search code examples
pythonpyqtpyqt5custom-widgets

How to properly scale thumbnails in my custom list widget?


Problem: I am trying to achieve a thumbnail viewer where each item (i.e. thumbnail and its page number label) are of fixed size. I am trying to scale the images so that they maintain aspect ratio inside these items. However, most images appear somewhat cropped in both dimensions.

MWE - Main Window

Desired result: thumbnails are scaled, without any cropping, and center-aligned within their item objects (horizontally and vertically), preferably with page number labels being a fixed number of px from the bottom border of parent item.

MWE:

import os
from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QSplitter

from PyQt5.QtCore import QSize, Qt
from PyQt5.QtGui import QPixmap

from PyQt5.QtWidgets import QListWidget, QVBoxLayout, QListWidgetItem, QListView, QAbstractItemView, QLabel

class CustomListWidget(QListWidget):
    def dropEvent(self, event):
        super().dropEvent(event)
        self.updatePageNumbers()

    def updatePageNumbers(self):
        for index in range(self.count()):
            item = self.item(index)
            widget = self.itemWidget(item)
            if widget is not None:  
                number_label = widget.findChild(QLabel, 'PageNumberLabel')
                if number_label:  
                    number_label.setText(str(index + 1))
            else:
                print(f"No widget found for item at index {index}")

class ThumbnailViewer(QWidget):
    def __init__(self):
        super().__init__()
        # self.iconSize = QSize(200, 220)
        # self.itemSize = QSize(220, 250)
        self._initUI()

    def _initUI(self):
        vbox = QVBoxLayout(self)
        self.listWidget = CustomListWidget()  
        self.listWidget.setDragDropMode(QAbstractItemView.InternalMove)
        self.listWidget.setFlow(QListView.LeftToRight)
        self.listWidget.setWrapping(True)
        self.listWidget.setResizeMode(QListView.Adjust)
        self.listWidget.setMovement(QListView.Snap)
        # self.listWidget.setIconSize(self.iconSize)

        self.listWidget.setStyleSheet("""
            QListWidget::item {
                border: 1px solid red;
            }
        """)

        folder = os.path.join(os.getcwd(), 'thumbs')
        files = [f for f in os.listdir(folder) if f.lower().endswith(('.png', '.jpg', '.jpeg')) and not f.startswith('.')]

        for idx, file in enumerate(files, start=1):  
            item, widget = self.loadImageItem(file, idx, folder=folder)
            self.listWidget.addItem(item)
            self.listWidget.setItemWidget(item, widget)
            print(f"Adding thumbnail to ThumbnailViewer: {file}")

        vbox.addWidget(self.listWidget)
        self.setLayout(vbox)

    def loadImageItem(self, file, pageNum, folder=None):
        widget = QWidget()
        iconLabel = QLabel()
        path = os.path.join(folder, file) if folder else file
        pixmap = QPixmap(path)

        max_width = 220
        max_height = 190  

        aspect_ratio = pixmap.width() / pixmap.height()
        if aspect_ratio > 1:  
            scale_factor = max_width / pixmap.width()
        else:  
            scale_factor = max_height / pixmap.height()

        new_width = int(pixmap.width() * scale_factor)
        new_height = int(pixmap.height() * scale_factor)

        scaled_pixmap = pixmap.scaled(new_width, new_height, Qt.KeepAspectRatio)
        iconLabel.setPixmap(scaled_pixmap)
        iconLabel.setAlignment(Qt.AlignCenter)

        numberLabel = QLabel(str(pageNum))
        numberLabel.setObjectName('PageNumberLabel')
        numberLabel.setAlignment(Qt.AlignHCenter)

        layout = QVBoxLayout()
        layout.addWidget(iconLabel)
        layout.addWidget(numberLabel)
        widget.setLayout(layout)

        item = QListWidgetItem()
        item.setSizeHint(QSize(220, 250))  
        return item, widget

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Thumbnail Viewer Test")

        central_widget = QWidget()
        layout = QVBoxLayout(central_widget)

        # Thumbnail Viewer
        self.thumbnail_viewer = ThumbnailViewer()
        layout.addWidget(self.thumbnail_viewer)

        # Add a blank space
        blank_space = QLabel("Blank Space")
        blank_space.setAlignment(Qt.AlignCenter)
        layout.addWidget(blank_space)

        self.setCentralWidget(central_widget)

if __name__ == "__main__":
    import sys
    app = QApplication(sys.argv)
    window = MainWindow()
    window.setGeometry(100, 100, 800, 600)
    window.show()
    sys.exit(app.exec_())

Solution

  • The cause: layout issues and aspect ratio

    You have two problems:

    1. as partially pointed out by the other answer, you're setting a size hint for the item that doesn't properly consider the possible maximum size of the pixmap (in fact, their height is the same, which is wrong);
    2. the ratio computation is wrong;

    About the first point, the problem is caused by the fact that Qt layout managers always have some margin (usually around 10 pixels) and spacing (4-5 pixels), and if the pixmap actually has a 220x190 size (or aspect ratio that result in it), then you end up with a width of at least 240 and a height of 210 plus the spacing and the label height. Since you're forcing the hint of the item to 220x250, if the size of the container widget is bigger, the only thing it can do is to crop its contents, starting with the pixmap.

    The other problem is that you're comparing the ratio of the image with "1", which is a 1:1 ratio (width and height coincide). In reality, though, your expected ratio is about 1.157 (max_width / max_height, or 220 / 190), meaning that any image having an aspect ratio between 1 and 1.157 will have the wrong resize ratio, as you assume the height as reference: even an image being actually 220x190 (or with the same ratio) would be resized with the wrong scale.

    Possible fixes

    The solution is theoretically easy.

    First, QPixmap.scaled() always automatically scales the image to the largest possible width/height of the given size, so there is really no need to check the ratio of the source, nor that of the target: as long as Qt.KeepAspectRatio is used, the image will always have the width or height based on the target size: if its original ratio would cause a larger height, then it will be scaled appropriately, and vice versa; at least the final width or height will always coincide with that of the given one.

    Then, the size hint of the item should be based on that of the actual widget:

    item.setSizeHint(widget.sizeHint())
    

    This would still be inconsistent (because of the variable sizes of the scaled pixmap), and even calling setUniformItemSizes() might have unwanted results under certain conditions, because it's always based on the size of the last element in the list, an assumption we may not be able to work with.

    A more appropriate solution

    The reality, though, is that all this can be completely ignored as long as the features of QListView and Qt item views in general are used, specifically by using item delegates.

    First, create a custom item delegate, by subclassing QStyledItemDelegate, then override its initStyleOption():

    class ThumbDelegate(QStyledItemDelegate):
        def initStyleOption(self, opt, index):
            super().initStyleOption(opt, index)
    
            opt.decorationPosition = opt.Top
            opt.decorationAlignment = Qt.AlignCenter
            opt.displayAlignment = Qt.AlignHCenter | Qt.AlignBottom
            opt.text = str(index.row() + 1)
    
            pal = opt.palette
            pal.setColor(pal.HighlightedText, pal.color(pal.Text))
    

    The initStyleOption() function is called by the delegate in order to "set up" each item whenever they need to compute its size hint or draw the contents. In the code above, other than calling the default implementation, we do the following:

    1. place the decoration (the icon) on top of the item;
    2. ensure that it's centered within its allowed space;
    3. align the display (the text) to the bottom, horizontally centered;
    4. overwrite the display with the row number (thus making the updatePageNumbers() function pointless);
    5. force the palette color of selected text, to avoid issues caused by setting the QSS on the view; alternatively, see the bottom comment in the QSS;

    Then, we set an explicit iconSize() on the list view (based on our preferred size, 220x190) and also tell it that each item has the same size.

    Finally, we just add each item with a QIcon() based on the file path, without any need for custom widgets. You can even add some padding to the style sheet that will resemble the layout margins, but without causing possible cropping.

    In the next example I switched to QDirIterator which is more immediate and scalable, especially because your current approach actually iterates the list twice (once for checking the file extension, then again to actually add the items).

    class ThumbnailViewer(QWidget):
        ...
        def _initUI(self):
            ...
            self.listWidget.setStyleSheet("""
                QListWidget::item {
                    border: 1px solid red;
                    padding: 10px;
                }
                /* alternative to the last lines of initStyleOption() above */
                QListWidget::item:selected {
                    color: palette(text);
                }
            """)
    
    
            self.listWidget.setItemDelegate(ThumbDelegate(self.listWidget))
            self.listWidget.setUniformItemSizes(True)
    
            it = QDirIterator(folder, ('*.png', '*.jpg', '*.jpeg'))
            while it.hasNext():
                path = it.next()
                item = QListWidgetItem(QIcon(path), '')
                self.listWidget.addItem(item)
    

    While it requires more knowledge about underlying Qt aspects, you can probably see how this solution is much simpler and more reliable than using an item widget.