You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

358 lines
12 KiB

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Image class definition
======================
**Project Name:** MakeHuman
**Product Home Page:** http://www.makehumancommunity.org/
**Github Code Home Page:** https://github.com/makehumancommunity/
**Authors:** Glynn Clements
**Copyright(c):** MakeHuman Team 2001-2020
**Licensing:** AGPL3
This file is part of MakeHuman Community (www.makehumancommunity.org).
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Abstract
--------
The image module contains the definition of the Image class, the container
that MakeHuman uses to handle images.
Image only depends on the numpy library, except when image have to be loaded
or saved to disk, in which case one of the back-ends (Qt or PIL) will have to
be imported (import happens only when needed).
"""
import time
import numpy as np
FILTER_NEAREST = 0 # Nearest neighbour resize filter (no real filtering)
FILTER_BILINEAR = 1 # Bi-linear filter
FILTER_BICUBIC = 2 # Bi-cubic filter (not supported with Qt, only PIL)
class Image(object):
"""Container for handling images.
It is equipped with the necessary methods that one needs for loading
and saving images and fetching their data, as well as many properties
providing information about the loaded image.
"""
def __init__(self, path=None, width=0, height=0, bitsPerPixel=32, components=None, data=None):
"""Image constructor.
The Image can be constructed by an existing Image, a QPixmap, a
QImage, loaded from a file path, or created empty.
To construct the Image by copying the data from the source,
pass the source as the first argument of the constructor.
``dest = Image(source)``
To create the Image by sharing the data of another Image, pass
the source Image as the data argument.
``dest = Image(data=sharedsource)``
The data argument can be a numpy array of 3 dimensions (w, h, c) which
will be used directly as the image's data, where w is the width, h is
the height, and c is the number of channels.
The data argument can also be a path to load.
To create an empty Image, leave path=None, and specify the width and
height. You can then optionally adjust the new Image's channels by
setting bitsPerPixel = 8, 16, 24, 32, or components = 1, 2, 3, 4,
which are equivalent to W (Grayscale), WA (Grayscale with Alpha),
RGB, and RGBA respectively.
"""
import image_qt as image_lib
if path is not None:
self._is_empty = False
if isinstance(path, Image):
# Create a copy of the image.
self._data = path.data.copy()
elif _isQPixmap(path):
qimg = path.toImage()
self._data = image_lib.load(qimg)
else: # Path string / QImage.
self._data = image_lib.load(path)
self.sourcePath = path
elif data is not None:
self._is_empty = False
if isinstance(data, Image):
# Share data between images.
self._data = data.data
elif isinstance(data, str):
self._data = image_lib.load(data)
else: # Data array.
self._data = data
else:
self._is_empty = True
if components is None:
if bitsPerPixel == 32:
components = 4
elif bitsPerPixel == 24:
components = 3
else:
raise NotImplementedError("bitsPerPixel must be 24 or 32")
self._data = np.empty((height, width, components), dtype=np.uint8)
self._data = np.ascontiguousarray(self._data)
self.modified = time.time()
@property
def size(self):
"""Return the size of the Image as a (width, height) tuple."""
h, w, c = self._data.shape
return (w, h)
@property
def width(self):
"""Return the width of the Image in pixels."""
h, w, c = self._data.shape
return w
@property
def height(self):
"""Return the height of the Image in pixels."""
h, w, c = self._data.shape
return h
@property
def components(self):
"""Return the number of the Image channels."""
h, w, c = self._data.shape
return c
@property
def bitsPerPixel(self):
"""Return the number of bits per pixel used for the Image."""
h, w, c = self._data.shape
return c * 8
@property
def data(self):
"""Return the numpy ndarray that contains the Image data."""
return self._data
def save(self, path):
"""Save the Image to a file."""
import image_qt as image_lib
image_lib.save(path, self._data)
def toQImage(self):
"""
Get a QImage copy of this Image.
Useful when the image should be shown in a Qt GUI
"""
import image_qt
# return image_qt.toQImage(self.data)
# ^ For some reason caused problems
if self.components == 1:
fmt = image_qt.QtGui.QImage.Format_RGB888
h, w, c = self.data.shape
data = np.repeat(self.data[:, :, 0], 3).reshape((h, w, 3))
elif self.components == 2:
fmt = image_qt.QtGui.QImage.Format_ARGB32
h, w, c = self.data.shape
data = np.repeat(self.data[:, :, 0], 3).reshape((h, w, 3))
data = np.insert(data, 3, values=self.data[:, :, 1], axis=2)
elif self.components == 3:
"""
fmt = image_qt.QtGui.QImage.Format_RGB888
data = self.data
"""
# The above causes a crash or misaligned image raster.
# Quickhack solution:
fmt = image_qt.QtGui.QImage.Format_ARGB32
_data = self.convert(components=4).data
# There appear to be channel mis-alignments, another hack:
data = np.zeros(_data.shape, dtype=_data.dtype)
data[:, :, :] = _data[:, :, [2, 1, 0, 3]]
else:
# components == 4
fmt = image_qt.QtGui.QImage.Format_ARGB32
data = self.data
return image_qt.QtGui.QImage(data.tostring(), data.shape[1], data.shape[0], fmt)
def resized_(self, width, height, filter=FILTER_NEAREST):
if filter == FILTER_NEAREST:
dw, dh = width, height
sw, sh = self.size
xmap = np.floor((np.arange(dw) + 0.5) * sw / dw).astype(int)
ymap = np.floor((np.arange(dh) + 0.5) * sh / dh).astype(int)
return self._data[ymap, :][:, xmap]
else:
# NOTE: bi-cubic filtering is not supported by Qt, use bi-linear
import image_qt
return image_qt.resized(self, width, height, filter=filter)
def resized(self, width, height, filter=FILTER_NEAREST):
"""Get a resized copy of the Image."""
return Image(data=self.resized_(width, height, filter))
def resize(self, width, height, filter=FILTER_NEAREST):
"""Resize the Image to a specified size."""
self._data = self.resized_(width, height, filter)
self.modified = time.time()
def blit(self, other, x, y):
"""Copy the contents of an Image to another.
The target image may have a different size."""
dh, dw, dc = self._data.shape
sh, sw, sc = other._data.shape
if sc != dc:
raise ValueError("source image has incorrect format")
sw = min(sw, dw - x)
sh = min(sh, dh - y)
self._data[y : y + sh, x : x + sw, :] = other._data
self.modified = time.time()
def flip_vertical(self):
"""Turn the Image upside down."""
return Image(data=self._data[::-1, :, :])
def flip_horizontal(self):
"""Flip the Image in the left-right direction."""
return Image(data=self._data[:, ::-1, :])
def __getitem__(self, xy):
"""Get the color of a specified pixel by using the
square brackets operator.
Example: my_color = my_image[(17, 42)]"""
if not isinstance(xy, tuple) or len(xy) != 2:
raise TypeError("tuple of length 2 expected")
x, y = xy
if not isinstance(x, int) or not isinstance(y, int):
raise TypeError("tuple of 2 ints expected")
if x < 0 or x >= self.width or y < 0 or y >= self.height:
raise IndexError("element index out of range")
pix = self._data[y, x, :]
if self.components == 4:
return (pix[0], pix[1], pix[2], pix[3])
elif self.components == 3:
return (pix[0], pix[1], pix[2], 255)
elif self.components == 2:
return (pix[0], pix[0], pix[0], pix[1])
elif self.components == 1:
return (pix[0], pix[0], pix[0], 255)
else:
return None
def __setitem__(self, xy, color):
"""Set the color of a pixel using the square brackets
operator.
Example: my_image[(17, 42)] = (0, 255, 64, 255)"""
if not isinstance(xy, tuple) or len(xy) != 2:
raise TypeError("tuple of length 2 expected")
x, y = xy
if not isinstance(x, int) or not isinstance(y, int):
raise TypeError("tuple of 2 ints expected")
if x < 0 or x >= self.width or y < 0 or y >= self.height:
raise IndexError("element index out of range")
if not isinstance(color, tuple):
raise TypeError("tuple expected")
self._data[y, x, :] = color
self.modified = time.time()
def convert(self, components):
"""Convert the Image to a different color format.
'components': The number of color channels the Image
will have after the conversion."""
if self.components == components:
return self
hasAlpha = self.components in (2, 4)
needAlpha = components in (2, 4)
if hasAlpha:
alpha = self._data[..., -1]
color = self._data[..., :-1]
else:
alpha = None
color = self._data
isMono = self.components in (1, 2)
toMono = components in (1, 2)
if isMono and not toMono:
color = np.dstack((color, color, color))
elif toMono and not isMono:
color = np.sum(color.astype(np.uint16), axis=-1) / 3
color = color.astype(np.uint8)[..., None]
if needAlpha and alpha is None:
alpha = np.zeros_like(color[..., :1]) + 255
if needAlpha:
data = np.dstack((color, alpha))
else:
data = color
return type(self)(data=data)
def markModified(self):
"""Mark the Image as modified."""
self.modified = time.time()
self._is_empty = False
@property
def isEmpty(self):
"""
Returns True if the Image is empty or new.
Returns False if the Image contains data or has been modified.
"""
return self._is_empty
def _isQPixmap(img):
"""
Test an image object for being a QPixmap instance if Qt libraries were
loaded in the application.
"""
import sys
if "PyQt5" in list(sys.modules.keys()):
import image_qt
return isinstance(img, image_qt.QtGui.QPixmap)
else:
return False