Skip to content

Instantly share code, notes, and snippets.

@yku12cn
Forked from ssokolow/image_widget.py
Last active May 23, 2022 22:15
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save yku12cn/afa2db2ff19202bfba080dff9a166c44 to your computer and use it in GitHub Desktop.
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
#!/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 :
@ssokolow
Copy link

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:

  1. 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.

  2. 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.

  3. Please re-add the import of QApplication. You broke the demonstration code at the bottom of the file:

    $ python3 image_widget.py ~/mpv-shot0001.jpg 
    Traceback (most recent call last):
      File "image_widget.py", line 215, in <module>
        main()
      File "image_widget.py", line 204, in main
        app = QApplication(sys.argv)
    NameError: name 'QApplication' is not defined
    
  4. Please change self.ori_size to self.orig_size. When I see ori, my mental voice pronounces it as "oh-ree", which is too far removed from "original".

  5. 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.)

  6. 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.)

  7. 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 two SaneThing.adaptScale(size) calls have the same type signature and no isinstance 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.)

@yku12cn
Copy link
Author

yku12cn commented Nov 23, 2019

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