-
-
Save yku12cn/afa2db2ff19202bfba080dff9a166c44 to your computer and use it in GitHub Desktop.
PyQt 5.x code for Just Do What I Mean™ image display in the presence of animated GIFs and containers with no fixed aspect ratio
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
"""Example code for a PyQt image-display widget which Just Works™ | |
TODO: Split this into a loader wrapper and a widget wrapper so it can be used | |
in designs which maintain a preloaded queue of upcoming images to improve | |
the perception of quick load times. | |
reworke adaptScale() so they have the same type signature. | |
Note from HeleleMama: | |
Based on Stephan Sokolow's work, I did the following: | |
1) re-arrange his structure to make things clearer. | |
2) Fix wrong image size while reloading image/Fix GIF flicker while loading | |
3) add adjustSize(), which performs like QLabel.adjustSize() | |
4) inherit from QLabel directly | |
""" | |
from __future__ import (absolute_import, division, print_function, | |
with_statement, unicode_literals) | |
__author__ = "Stephan Sokolow (deitarion/SSokolow); HeleleMama" | |
__license__ = "MIT" | |
from PyQt5.QtCore import QSize, Qt | |
from PyQt5.QtGui import QImageReader, QMovie, QPalette, QPixmap | |
from PyQt5.QtWidgets import QLabel | |
class SaneQMovie(QMovie): | |
"""add adaptScale method""" | |
def __init__(self, *args, **kwargs): | |
super(SaneQMovie, self).__init__(*args, **kwargs) | |
self.jumpToFrame(0) | |
# Save the original aspect for later use | |
self.orig_size = self.currentImage().size() | |
self.aspect = self.orig_size.width() / self.orig_size.height() | |
def adaptScale(self, size): | |
"""Manually implement aspect-preserving scaling for QMovie""" | |
# Thanks to Spencer @ https://stackoverflow.com/a/50166220/435253 | |
# for figuring out that this approach must be taken to get smooth | |
# up-scaling out of QMovie. | |
width = size.height() * self.aspect | |
if width <= size.width(): | |
n_size = QSize(width, size.height()) | |
else: | |
height = size.width() / self.aspect | |
n_size = QSize(size.width(), height) | |
self.setScaledSize(n_size) | |
def size(self): | |
"""Return original size""" | |
return self.orig_size | |
class SaneQPixmap(QPixmap): | |
"""add adaptScale method""" | |
def adaptScale(self, size): | |
"""aspect-preserving scaling for QPixmap""" | |
# To avoid having to change which widgets are hidden and shown, | |
# do our upscaling manually. | |
# | |
# This probably won't be suitable for widgets intended to be | |
# resized as part of normal operation (aside from initially, when | |
# the window appears) but it works well enough for my use cases and | |
# was the quickest, simplest thing to implement. | |
# | |
# If your problem is downscaling very large images, I'd start by | |
# making this one- or two-line change to see if it's good enough: | |
# 1. Use Qt.FastTransformation to scale to the closest power of | |
# two (eg. 1/2, 1/4, 1/8, etc.) that's still bigger and gives a | |
# decent looking intermediate result. | |
# 2. Use Qt.SmoothTransform to take the final step to the desired | |
# size. | |
# | |
# If it's not or you need actual animation, you'll want to look up | |
# how to do aspect-preserving display of images and animations | |
# under QML (embeddable in a QWidget GUI using QQuickWidget) so Qt | |
# can offload the scaling to the GPU. | |
return self.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) | |
def _sizeCheck(c_size, n_size): | |
"""Check if new size alter current dimension""" | |
if c_size.width() == n_size.width() and c_size.height() <= n_size.height(): | |
return False | |
if c_size.height() == n_size.height() and c_size.width() <= n_size.width(): | |
return False | |
return True | |
class SaneDefaultsImageLabel(QLabel): | |
"""Compound widget to work around some shortcomings in Qt image display. | |
- Animated GIFs will animate, like in a browser, by transparently switching | |
between QImage and QMovie internally depending on the number of frames | |
detected by QImageReader. | |
- Content will scale up or down to fit the widget while preserving its | |
aspect ratio and will do so without imposing a minimum size of 100%. | |
- Letterbox/pillarbox borders will default to transparent. | |
(It's a bit of a toss-up whether an application will want this or the | |
default window background colour, so this defaults to the choice that | |
provides an example of how to accomplish it.) | |
Note that QImageReader doesn't have an equivalent to GdkPixbufLoader's | |
`area-prepared` and `area-updated` signals, so incremental display for | |
for high-speed scanning (ie. hitting "next" based on a partially loaded | |
images) isn't really possible. The closest one can get is to experiment | |
with QImageReader's support for loading just part of a JPEG file to see if | |
it can be done without significantly adding to the whole-image load time. | |
(https://wiki.qt.io/Loading_Large_Images) | |
""" | |
def __init__(self): | |
super(SaneDefaultsImageLabel, self).__init__() | |
# Default Alignment set to Center | |
self.setAlignment(Qt.AlignCenter) | |
# Set the letterbox/pillarbox bars to be transparent | |
# https://wiki.qt.io/How_to_Change_the_Background_Color_of_QWidget | |
pal = self.palette() | |
pal.setColor(QPalette.Background, Qt.transparent) | |
self.setAutoFillBackground(True) | |
self.setPalette(pal) | |
# Reserve a slot for actual content | |
self.content = None | |
def load(self, source, adaptSize=True): | |
"""Load anything that QImageReader or QMovie constructors accept | |
adaptSize=True: Initial image size to fit container | |
adaptSize=False: Set container's size the same as image | |
""" | |
# Use QImageReader to identify animated GIFs for separate handling | |
# (Thanks to https://stackoverflow.com/a/20674469/435253 for this) | |
size = QSize(self.width(), self.height()) | |
image_reader = QImageReader(source) | |
if image_reader.supportsAnimation() and image_reader.imageCount() > 1: | |
# Set content as Movie | |
self.content = SaneQMovie(source) | |
# Adjust the widget size | |
if adaptSize: | |
self.content.adaptScale(size) | |
else: | |
# Resizing container will trigger resizeEvent() | |
self.resize(self.content.size()) | |
# Start Movie replay | |
self.setMovie(self.content) | |
self.content.start() | |
else: | |
# Set content as Image | |
self.content = SaneQPixmap(image_reader.read()) | |
# Adjust the widget size | |
if adaptSize: | |
self.setPixmap(self.content.adaptScale(size)) | |
else: | |
# Resizing container will trigger resizeEvent() | |
self.resize(self.content.size()) | |
self.setPixmap(self.content) | |
# Keep the image from preventing downscaling | |
self.setMinimumSize(1, 1) | |
def adjustSize(self): | |
"""Reset content size""" | |
# Retrieve content size | |
if self.content: | |
size = self.content.size() | |
# Reset container size | |
# Let resizeEvent() to do the rest | |
self.resize(size) | |
def resizeEvent(self, _event=None): | |
"""Resize handler to update dimensions of displayed image/animation""" | |
size = QSize(self.width(), self.height()) | |
# super(SaneDefaultsImageLabel, self).resizeEvent(_event) | |
# Check both content and current label to prevent false triggering | |
if isinstance(self.content, SaneQMovie) and self.movie(): | |
if _sizeCheck(self.content.currentImage().size(), size): | |
self.content.adaptScale(size) | |
# Check both content and current label to prevent false triggering | |
elif isinstance(self.content, SaneQPixmap) and self.pixmap(): | |
# Don't waste CPU generating a new pixmap if the resize didn't | |
# alter the dimension that's currently bounding its size | |
if _sizeCheck(self.pixmap().size(), size): | |
self.setPixmap(self.content.adaptScale(size)) | |
def main(): | |
"""Main entry point for demonstration code""" | |
# import sys | |
# from PyQt5.QtWidgets import QApplication | |
if len(sys.argv) != 2: | |
print("Usage: {} <image path>".format(sys.argv[0])) | |
sys.exit(1) | |
# I don't know how reliable it is, but making `app` a global which outlives | |
# `main()` seems to fix the "segmentation fault on exit" bug caused by | |
# Python and Qt disagreeing on the destruction order for the QObject tree | |
# and it's certainly the most concise solution I've yet found. | |
global app # pylint: disable=global-statement, global-variable-undefined | |
app = QApplication(sys.argv) | |
# Take advantage of how any QWidget subclass can be used as a top-level | |
# window for demonstration purposes | |
window = SaneDefaultsImageLabel() | |
window.load(sys.argv[1]) | |
window.show() | |
sys.exit(app.exec_()) | |
if __name__ == "__main__": | |
import sys | |
from PyQt5.QtWidgets import QApplication | |
main() | |
# vim: set sw=4 sts=4 expandtab : |
I guess, gist is more like a "sharing platform" rather than "collaborating platform".
I'm using this module in my other project. Since I also add other functions that suits my need, it became very inconvenient for merging.
I have thought about letting adaptScale have the same type signature. However, the QMovie and QPixmap works in a totally different way which makes this not an easy task. Actually, the way how QMovie works, is by updating QLabel's Pixmap. In another word, when a QMovie object is assigned to QLabel, the pixmap of that label will be automatically updated by that QMovie object. On the other hand, you need to manually update Pixmap.
Anyway, I will take a look later.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Sorry for the delayed reply. Busy day catching up on some other stuff that slipped before you contacted me.
Anyway, here's my commentary on what either you or I will need to do before it'll get merged back into my Gist:
Please revert the replacement of parentheses with trailing
\
characters on lines 181 and 184. I don't permit code where the presence or absence of invisible trailing whitespace alters how the code is interpreted.Thank you for the cleanup around QMovie vs. QPixmap. The last time I touched that code, I hadn't slept very well, so my eye for refactoring wasn't the greatest.
Please re-add the import of
QApplication
. You broke the demonstration code at the bottom of the file:Please change
self.ori_size
toself.orig_size
. When I seeori
, my mental voice pronounces it as "oh-ree", which is too far removed from "original".Please insert blank lines above and below the "Note from HeleMama:" section in the docstring. (Proper formatting for docstrings uses a blank line as a paragraph separator because reStructuredText parsers consider each block of lines to be a paragraph to be un-word-wrapped. It's also standard to have a one-line synopsis at the top, then a blank line, then detailed description. I extend that pattern with "then a blank line, then any TODOs and other such "maintainer notes".)
(Also, be aware that I may rewrite that into a proper
Changelog
section later.)In order to keep it machine-parseable, please change the
__author__
string to"Stephan Sokolow (deitarion/SSokolow); HeleleMama"
(Like a comma-separated list, but using semicolons because literal commas could theoretically be part of a name. It's standard enough that some tools, like Tellico, have their suggestion drop-downs tuned to recognize;
as a list separator.)I'm fine taking over and doing it myself when I can find time but, if you're willing, my first impulse is to ask for
adaptScale
to be reworked even further so the twoSaneThing.adaptScale(size)
calls have the same type signature and noisinstance
is needed to choose how to call them. (As-is, they feel like they're an open opportunity for a bug to creep in during refactoring.)(I can do all this myself if you're unwilling... but it may take longer.)