## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.
"""Class for representing image tiles."""
from typing import TYPE_CHECKING, Any, Optional
from PIL import Image, ImageQt
from PySide6 import QtCore, QtGui
from zooui.logger import get_logger
if TYPE_CHECKING:
from PySide6.QtGui import QPainter
TileID = tuple[str, int, int, int]
[docs]
class Tile:
"""
Constructor:
Tile(image)
Parameters :
image : QImage
Tile(image) --> None
A simple wrapper around image data (PIL/QImage) with operations:
- crop(), resize(), save(), draw()
- merged() - Combines 4 tiles into one (2×2 grid layout)
"""
def __init__(self, image: Any) -> None:
"""
Constructor:
Tile(image)
Parameters :
image : Any
Tile(image) --> None
Create a new tile with the given image.
checks if image type is ImageQt of QtGui.QImage if not calls
ImageQt.ImageQt to try convert given image to ImageQt type.
"""
if image.__class__ is ImageQt or type(image) is QtGui.QImage:
self.__image = image
else:
try:
self.__image = ImageQt.ImageQt(image)
except Exception as e:
get_logger("Tile").error("ERROR on tile __init__: %s", e)
[docs]
def crop(self, bbox: tuple[int, int, int, int]) -> "Tile":
"""
Method :
Tile.crop(bbox)
Parameters :
bbox : Tuple[int, int, int, int]
Tile.crop(bbox) --> Tile
Return the region of the tile contained in the bounding box `bbox`
(x1,y1,x2,y2).
"""
x, y, x2, y2 = bbox
w = x2 - x
h = y2 - y
return Tile(self.__image.copy(int(x), int(y), int(w), int(h)))
[docs]
def resize(self, width: int, height: int) -> "Tile":
"""
Method :
Tile.resize(width, height)
Parameters :
width : int
height : int
Tile.resize(width, height) --> Tile
Return a resized copy of the tile by calling ImageQt.scaled() method.
"""
# Use shortcut enum values for compatibility with PySide6 versions
return Tile(
self.__image.scaled(int(width), int(height), QtCore.Qt.IgnoreAspectRatio, QtCore.Qt.FastTransformation)
)
[docs]
def save(self, filename: str) -> None:
"""
Method :
Tile.save(filename)
Parameters :
filename : str
Tile.save(filename) --> None
Save the tile to the location given by `filename`.
Converts the QImage to a PIL Image and saves via Pillow, avoiding
QImage.save() which can segfault when a QApplication is live
(known PySide6 issue when tiling runs after a QApplication is
created, e.g. in test suites).
"""
if self.__image.width() == 0 or self.__image.height() == 0:
raise ValueError("Cannot save empty tile")
ptr = self.__image.constBits()
if ptr:
# QImage Format_RGB32 stores 4 bytes per pixel in memory:
# [B, G, R, X] on little-endian. Always read as BGRA / RGBA
# (4 bytes per pixel) and then convert to RGB to discard the
# unused alpha/padding byte.
pil_image = Image.frombuffer(
"RGBA",
(self.__image.width(), self.__image.height()),
ptr,
"raw",
"BGRA",
self.__image.bytesPerLine(),
1,
)
pil_image = pil_image.convert("RGB")
pil_image.save(filename)
else:
# Fallback for images backed by GPU or non-standard memory
self.__image.save(filename)
[docs]
def draw(self, painter: "QPainter", x: int, y: int) -> None:
"""
Method :
Tile.draw(painter, x, y)
Parameters :
painter : QPainter
x : int
y : int
Tile.draw(painter, x, y) --> None
Draw the tile on the given painter: 'QtGui.QPainter' at the given position.
"""
painter.drawImage(x, y, self.__image)
@property
def size(self) -> tuple[int, int]:
"""
Property :
Tile.size
Parameters :
None
Tile.size --> Tuple[int, int]
Returns the dimensions of the tile calling ImageQt.width and ImageQt.height
methods.
"""
return (self.__image.width(), self.__image.height())
[docs]
def new(width: int, height: int) -> Tile:
"""
Function :
new(width, height)
Parameters :
width : int
height : int
new(width, height) --> Tile
Create a new tile with the given dimensions calling QtGui.QImage() istance.
"""
return Tile(QtGui.QImage(width, height, QtGui.QImage.Format.Format_RGB32))
[docs]
def fromstring(string: str, width: int, height: int) -> Tile:
"""
Function :
fromstring(string, width, height)
Parameters :
string : str
width : int
height : int
fromstring(string, width, height) --> Tile
Create a new tile from a `string` of raw pixels, with the given
dimensions, calling Image.frombytes() class instance.
"""
return Tile(Image.frombytes("RGB", (width, height), string.encode("latin-1")))
[docs]
def merged(t1: "Tile", t2: Optional["Tile"], t3: Optional["Tile"], t4: Optional["Tile"]) -> "Tile":
"""
Function :
merged(t1, t2, t3, t4)
Parameters :
t1 : Tile
t2 : Optional[Tile]
t3 : Optional[Tile]
t4 : Optional[Tile]
merged(t1, t2, t3, t4) --> Tile
Merge the given tiles into a single tile by freating new size ImageQt with
QtGui.QImage class instance and then drawing merged tiles with QtGui.QPainter()
class instance.
`t1` must be a Tile, but any or all of `t2`,`t3`,`t4` may be None, in which
case they will be ignored.
"""
## tiles are merged in the following layout:
## +---------+
## | t1 | t2 |
## |----+----|
## | t3 | t4 |
## +---------+
tilewidth, tileheight = t1.size
if t2:
tilewidth += t2.size[0]
if t3:
tileheight += t3.size[1]
painter = QtGui.QPainter()
image = QtGui.QImage(tilewidth, tileheight, QtGui.QImage.Format.Format_RGB32)
painter.begin(image)
if t1:
t1.draw(painter, 0, 0)
if t2:
t2.draw(painter, t1.size[0], 0)
if t3:
t3.draw(painter, 0, t1.size[1])
if t4:
t4.draw(painter, t1.size[0], t1.size[1])
painter.end()
return Tile(image)