Merge pull request #8331 from radarhere/imageqt

This commit is contained in:
Hugo van Kemenade 2024-08-27 22:25:39 +03:00 committed by GitHub
commit 56e3147403
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 67 additions and 44 deletions

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Union
import pytest import pytest
@ -8,6 +9,20 @@ from PIL import Image, ImageQt
from .helper import assert_image_equal_tofile, assert_image_similar, hopper from .helper import assert_image_equal_tofile, assert_image_similar, hopper
if TYPE_CHECKING:
import PyQt6
import PySide6
QApplication = Union[PyQt6.QtWidgets.QApplication, PySide6.QtWidgets.QApplication]
QHBoxLayout = Union[PyQt6.QtWidgets.QHBoxLayout, PySide6.QtWidgets.QHBoxLayout]
QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage]
QLabel = Union[PyQt6.QtWidgets.QLabel, PySide6.QtWidgets.QLabel]
QPainter = Union[PyQt6.QtGui.QPainter, PySide6.QtGui.QPainter]
QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap]
QPoint = Union[PyQt6.QtCore.QPoint, PySide6.QtCore.QPoint]
QRegion = Union[PyQt6.QtGui.QRegion, PySide6.QtGui.QRegion]
QWidget = Union[PyQt6.QtWidgets.QWidget, PySide6.QtWidgets.QWidget]
if ImageQt.qt_is_installed: if ImageQt.qt_is_installed:
from PIL.ImageQt import QPixmap from PIL.ImageQt import QPixmap
@ -20,7 +35,7 @@ if ImageQt.qt_is_installed:
from PySide6.QtGui import QImage, QPainter, QRegion from PySide6.QtGui import QImage, QPainter, QRegion
from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
class Example(QWidget): class Example(QWidget): # type: ignore[misc]
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -28,11 +43,12 @@ if ImageQt.qt_is_installed:
qimage = ImageQt.ImageQt(img) qimage = ImageQt.ImageQt(img)
pixmap1 = ImageQt.QPixmap.fromImage(qimage) pixmap1 = getattr(ImageQt.QPixmap, "fromImage")(qimage)
QHBoxLayout(self) # hbox # hbox
QHBoxLayout(self) # type: ignore[operator]
lbl = QLabel(self) lbl = QLabel(self) # type: ignore[operator]
# Segfault in the problem # Segfault in the problem
lbl.setPixmap(pixmap1.copy()) lbl.setPixmap(pixmap1.copy())
@ -46,7 +62,7 @@ def roundtrip(expected: Image.Image) -> None:
@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed")
def test_sanity(tmp_path: Path) -> None: def test_sanity(tmp_path: Path) -> None:
# Segfault test # Segfault test
app: QApplication | None = QApplication([]) app: QApplication | None = QApplication([]) # type: ignore[operator]
ex = Example() ex = Example()
assert app # Silence warning assert app # Silence warning
assert ex # Silence warning assert ex # Silence warning
@ -56,7 +72,7 @@ def test_sanity(tmp_path: Path) -> None:
im = hopper(mode) im = hopper(mode)
data = ImageQt.toqpixmap(im) data = ImageQt.toqpixmap(im)
assert isinstance(data, QPixmap) assert data.__class__.__name__ == "QPixmap"
assert not data.isNull() assert not data.isNull()
# Test saving the file # Test saving the file
@ -64,14 +80,14 @@ def test_sanity(tmp_path: Path) -> None:
data.save(tempfile) data.save(tempfile)
# Render the image # Render the image
qimage = ImageQt.ImageQt(im) imageqt = ImageQt.ImageQt(im)
data = QPixmap.fromImage(qimage) data = getattr(QPixmap, "fromImage")(imageqt)
qt_format = QImage.Format if ImageQt.qt_version == "6" else QImage qt_format = getattr(QImage, "Format") if ImageQt.qt_version == "6" else QImage
qimage = QImage(128, 128, qt_format.Format_ARGB32) qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32")) # type: ignore[operator]
painter = QPainter(qimage) painter = QPainter(qimage) # type: ignore[operator]
image_label = QLabel() image_label = QLabel() # type: ignore[operator]
image_label.setPixmap(data) image_label.setPixmap(data)
image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) # type: ignore[operator]
painter.end() painter.end()
rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png") rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png")
qimage.save(rendered_tempfile) qimage.save(rendered_tempfile)

View File

@ -21,7 +21,7 @@ def test_sanity(mode: str, tmp_path: Path) -> None:
src = hopper(mode) src = hopper(mode)
data = ImageQt.toqimage(src) data = ImageQt.toqimage(src)
assert isinstance(data, QImage) assert isinstance(data, QImage) # type: ignore[arg-type, misc]
assert not data.isNull() assert not data.isNull()
# reload directly from the qimage # reload directly from the qimage

View File

@ -166,5 +166,4 @@ warn_unused_ignores = true
exclude = [ exclude = [
'^Tests/oss-fuzz/fuzz_font.py$', '^Tests/oss-fuzz/fuzz_font.py$',
'^Tests/oss-fuzz/fuzz_pillow.py$', '^Tests/oss-fuzz/fuzz_pillow.py$',
'^Tests/test_qt_image_qapplication.py$',
] ]

View File

@ -71,7 +71,7 @@ def Ghostscript(
fp: IO[bytes], fp: IO[bytes],
scale: int = 1, scale: int = 1,
transparency: bool = False, transparency: bool = False,
) -> Image.Image: ) -> Image.core.ImagingCore:
"""Render an image using Ghostscript""" """Render an image using Ghostscript"""
global gs_binary global gs_binary
if not has_ghostscript(): if not has_ghostscript():

View File

@ -223,7 +223,7 @@ if TYPE_CHECKING:
from IPython.lib.pretty import PrettyPrinter from IPython.lib.pretty import PrettyPrinter
from . import ImageFile, ImageFilter, ImagePalette, TiffImagePlugin from . import ImageFile, ImageFilter, ImagePalette, ImageQt, TiffImagePlugin
from ._typing import NumpyArray, StrOrBytesPath, TypeGuard from ._typing import NumpyArray, StrOrBytesPath, TypeGuard
ID: list[str] = [] ID: list[str] = []
OPEN: dict[ OPEN: dict[
@ -2978,7 +2978,7 @@ class Image:
self.load() self.load()
return self._new(self.im.effect_spread(distance)) return self._new(self.im.effect_spread(distance))
def toqimage(self): def toqimage(self) -> ImageQt.ImageQt:
"""Returns a QImage copy of this image""" """Returns a QImage copy of this image"""
from . import ImageQt from . import ImageQt
@ -2987,7 +2987,7 @@ class Image:
raise ImportError(msg) raise ImportError(msg)
return ImageQt.toqimage(self) return ImageQt.toqimage(self)
def toqpixmap(self): def toqpixmap(self) -> ImageQt.QPixmap:
"""Returns a QPixmap copy of this image""" """Returns a QPixmap copy of this image"""
from . import ImageQt from . import ImageQt
@ -3314,7 +3314,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image:
return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) return frombuffer(mode, size, obj, "raw", rawmode, 0, 1)
def fromqimage(im) -> ImageFile.ImageFile: def fromqimage(im: ImageQt.QImage) -> ImageFile.ImageFile:
"""Creates an image instance from a QImage image""" """Creates an image instance from a QImage image"""
from . import ImageQt from . import ImageQt
@ -3324,7 +3324,7 @@ def fromqimage(im) -> ImageFile.ImageFile:
return ImageQt.fromqimage(im) return ImageQt.fromqimage(im)
def fromqpixmap(im) -> ImageFile.ImageFile: def fromqpixmap(im: ImageQt.QPixmap) -> ImageFile.ImageFile:
"""Creates an image instance from a QPixmap image""" """Creates an image instance from a QPixmap image"""
from . import ImageQt from . import ImageQt

View File

@ -19,14 +19,23 @@ from __future__ import annotations
import sys import sys
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Callable from typing import TYPE_CHECKING, Any, Callable, Union
from . import Image from . import Image
from ._util import is_path from ._util import is_path
if TYPE_CHECKING: if TYPE_CHECKING:
import PyQt6
import PySide6
from . import ImageFile from . import ImageFile
QBuffer: type
QByteArray = Union[PyQt6.QtCore.QByteArray, PySide6.QtCore.QByteArray]
QIODevice = Union[PyQt6.QtCore.QIODevice, PySide6.QtCore.QIODevice]
QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage]
QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap]
qt_version: str | None qt_version: str | None
qt_versions = [ qt_versions = [
["6", "PyQt6"], ["6", "PyQt6"],
@ -37,10 +46,6 @@ qt_versions = [
qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True)
for version, qt_module in qt_versions: for version, qt_module in qt_versions:
try: try:
QBuffer: type
QIODevice: type
QImage: type
QPixmap: type
qRgba: Callable[[int, int, int, int], int] qRgba: Callable[[int, int, int, int], int]
if qt_module == "PyQt6": if qt_module == "PyQt6":
from PyQt6.QtCore import QBuffer, QIODevice from PyQt6.QtCore import QBuffer, QIODevice
@ -65,19 +70,20 @@ def rgb(r: int, g: int, b: int, a: int = 255) -> int:
return qRgba(r, g, b, a) & 0xFFFFFFFF return qRgba(r, g, b, a) & 0xFFFFFFFF
def fromqimage(im): def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile:
""" """
:param im: QImage or PIL ImageQt object :param im: QImage or PIL ImageQt object
""" """
buffer = QBuffer() buffer = QBuffer()
qt_openmode: object
if qt_version == "6": if qt_version == "6":
try: try:
qt_openmode = QIODevice.OpenModeFlag qt_openmode = getattr(QIODevice, "OpenModeFlag")
except AttributeError: except AttributeError:
qt_openmode = QIODevice.OpenMode qt_openmode = getattr(QIODevice, "OpenMode")
else: else:
qt_openmode = QIODevice qt_openmode = QIODevice
buffer.open(qt_openmode.ReadWrite) buffer.open(getattr(qt_openmode, "ReadWrite"))
# preserve alpha channel with png # preserve alpha channel with png
# otherwise ppm is more friendly with Image.open # otherwise ppm is more friendly with Image.open
if im.hasAlphaChannel(): if im.hasAlphaChannel():
@ -93,7 +99,7 @@ def fromqimage(im):
return Image.open(b) return Image.open(b)
def fromqpixmap(im) -> ImageFile.ImageFile: def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile:
return fromqimage(im) return fromqimage(im)
@ -123,7 +129,7 @@ def align8to32(bytes: bytes, width: int, mode: str) -> bytes:
return b"".join(new_data) return b"".join(new_data)
def _toqclass_helper(im): def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]:
data = None data = None
colortable = None colortable = None
exclusive_fp = False exclusive_fp = False
@ -135,30 +141,32 @@ def _toqclass_helper(im):
if is_path(im): if is_path(im):
im = Image.open(im) im = Image.open(im)
exclusive_fp = True exclusive_fp = True
assert isinstance(im, Image.Image)
qt_format = QImage.Format if qt_version == "6" else QImage qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage
if im.mode == "1": if im.mode == "1":
format = qt_format.Format_Mono format = getattr(qt_format, "Format_Mono")
elif im.mode == "L": elif im.mode == "L":
format = qt_format.Format_Indexed8 format = getattr(qt_format, "Format_Indexed8")
colortable = [rgb(i, i, i) for i in range(256)] colortable = [rgb(i, i, i) for i in range(256)]
elif im.mode == "P": elif im.mode == "P":
format = qt_format.Format_Indexed8 format = getattr(qt_format, "Format_Indexed8")
palette = im.getpalette() palette = im.getpalette()
assert palette is not None
colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)] colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)]
elif im.mode == "RGB": elif im.mode == "RGB":
# Populate the 4th channel with 255 # Populate the 4th channel with 255
im = im.convert("RGBA") im = im.convert("RGBA")
data = im.tobytes("raw", "BGRA") data = im.tobytes("raw", "BGRA")
format = qt_format.Format_RGB32 format = getattr(qt_format, "Format_RGB32")
elif im.mode == "RGBA": elif im.mode == "RGBA":
data = im.tobytes("raw", "BGRA") data = im.tobytes("raw", "BGRA")
format = qt_format.Format_ARGB32 format = getattr(qt_format, "Format_ARGB32")
elif im.mode == "I;16": elif im.mode == "I;16":
im = im.point(lambda i: i * 256) im = im.point(lambda i: i * 256)
format = qt_format.Format_Grayscale16 format = getattr(qt_format, "Format_Grayscale16")
else: else:
if exclusive_fp: if exclusive_fp:
im.close() im.close()
@ -174,8 +182,8 @@ def _toqclass_helper(im):
if qt_is_installed: if qt_is_installed:
class ImageQt(QImage): class ImageQt(QImage): # type: ignore[misc]
def __init__(self, im) -> None: def __init__(self, im: Image.Image | str | QByteArray) -> None:
""" """
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
class. class.
@ -199,10 +207,10 @@ if qt_is_installed:
self.setColorTable(im_data["colortable"]) self.setColorTable(im_data["colortable"])
def toqimage(im) -> ImageQt: def toqimage(im: Image.Image | str | QByteArray) -> ImageQt:
return ImageQt(im) return ImageQt(im)
def toqpixmap(im): def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap:
qimage = toqimage(im) qimage = toqimage(im)
return QPixmap.fromImage(qimage) return getattr(QPixmap, "fromImage")(qimage)