Merge remote-tracking branch 'qubesos/pr/25'

* qubesos/pr/25:
  replace tinting algorithm with one that partially preserves saturation too
  reimplement tint algorithm with numpy for reasonable performance
  use PIL image library instead of ImageMagick to load/save images when tinting
  add Python pillow and numpy dependencies
  remove unused cairo import
This commit is contained in:
Marek Marczykowski-Górecki 2017-11-21 05:14:11 +01:00
commit 0a7d2c0789
No known key found for this signature in database
GPG Key ID: 063938BA42CFA724
4 changed files with 83 additions and 32 deletions

View File

@ -45,7 +45,7 @@ make -C imgconverter all
} }
package_qubes-vm-utils() { package_qubes-vm-utils() {
depends=(qubes-libvchan imagemagick python2-cairo) depends=(qubes-libvchan imagemagick python2-cairo python2-pillow python2-numpy python-pillow python-numpy)
install=PKGBUILD-qubes-vm-utils.install install=PKGBUILD-qubes-vm-utils.install
# Install all for python2 # Install all for python2

2
debian/control vendored
View File

@ -17,7 +17,7 @@ Vcs-Git: http://dsg.is/qubes/qubes-linux-utils.git
Package: qubes-utils Package: qubes-utils
Architecture: any Architecture: any
Depends: libvchan-xen, lsb-base, ${shlibs:Depends}, ${misc:Depends} Depends: libvchan-xen, lsb-base, python-pil, python-numpy, python3-pil, python3-numpy, ${shlibs:Depends}, ${misc:Depends}
Conflicts: qubes-linux-utils Conflicts: qubes-linux-utils
Breaks: qubes-core-agent (<< 3.1.4) Breaks: qubes-core-agent (<< 3.1.4)
Recommends: python2.7 Recommends: python2.7

View File

@ -23,8 +23,6 @@ Toolkit for secure transfer and conversion of images between Qubes VMs.'''
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import colorsys
import math
import os import os
import re import re
try: try:
@ -35,7 +33,8 @@ import subprocess
import sys import sys
import unittest import unittest
import cairo import PIL.Image
import numpy
# those are for "zOMG UlTRa HD! WalLLpapPer 8K!!1!" to work seamlessly; # those are for "zOMG UlTRa HD! WalLLpapPer 8K!!1!" to work seamlessly;
# 8192 * 5120 * 4 B = 160 MiB, so DoS by memory exhaustion is unlikely # 8192 * 5120 * 4 B = 160 MiB, so DoS by memory exhaustion is unlikely
@ -73,6 +72,12 @@ get_from_stream(), get_from_vm(), get_xdg_icon_from_vm(), get_through_dvm()'''
if p.wait(): if p.wait():
raise Exception('Conversion failed') raise Exception('Conversion failed')
def save_pil(self, dst):
'''Save image to disk using PIL.'''
img = PIL.Image.frombytes('RGBA', self._size, self._rgba)
img.save(dst)
@property @property
def data(self): def data(self):
return self._rgba return self._rgba
@ -88,27 +93,66 @@ get_from_stream(), get_from_vm(), get_xdg_icon_from_vm(), get_through_dvm()'''
def tint(self, colour): def tint(self, colour):
'''Return new tinted image''' '''Return new tinted image'''
r, g, b = hex_to_float(colour) tr, tg, tb = hex_to_int(colour)
h, _, s = colorsys.rgb_to_hls(r, g, b) tM = max(tr, tg, tb)
result = BytesIO() tm = min(tr, tg, tb)
# duplicate the whole loop for performance reasons # (trn/tdn, tgn/tdn, tbn/tdn) is the tint color with maximum saturation
if sys.version_info[0] < 3: if tm == tM:
for i in range(0, self._size[0] * self._size[1] * 4, 4): trn = 1
r, g, b, a = tuple(ord(c) / 255. for c in self._rgba[i:i+4]) tgn = 1
_, l, _ = colorsys.rgb_to_hls(r, g, b) tbn = 1
r, g, b = colorsys.hls_to_rgb(h, l, s) tdn = 2
result.write(b''.join(chr(int(i * 255)) for i in [r, g, b, a]))
else: else:
for i in range(0, self._size[0] * self._size[1] * 4, 4): trn = tr - tm
r, g, b, a = tuple(c / 255. for c in self._rgba[i:i + 4]) tgn = tg - tm
_, l, _ = colorsys.rgb_to_hls(r, g, b) tbn = tb - tm
r, g, b = colorsys.hls_to_rgb(h, l, s) tdn = tM - tm
result.write(bytes(int(i * 255) for i in [r, g, b, a])) # use a 1D image representation since we only process a single pixel at a time
pixels = self._size[0] * self._size[1]
x = numpy.fromstring(self._rgba, 'B').reshape(pixels, 4)
r = x[:, 0]
g = x[:, 1]
b = x[:, 2]
a = x[:, 3]
M = numpy.maximum(numpy.maximum(r, g), b).astype('u4')
m = numpy.minimum(numpy.minimum(r, g), b).astype('u4')
return self.__class__(rgba=result.getvalue(), size=self._size) # Tn/Td is how much chroma range is reserved for the tint color
# 0 -> greyscale image becomes greyscale image
# 1 -> image becomes solid tint color
Tn = 1
Td = 4
# set chroma to the original pixel chroma mapped to the Tn/Td .. 1 range
# float c2 = (Tn/Td) + (1.0 - Tn/Td) * c
# set lightness to the original pixel lightness mapped to the range for the new chroma value
# float m2 = m * (1.0 - c2) / (1.0 - c)
c = M - m
c2 = (Tn * 255) + (Td - Tn) * c
c2d = Td
m2 = ((255 * c2d) - c2) * m
# the maximum avoids division by 0 when c = 255 (m2 is 0 anyway, so m2d doesn't matter)
m2d = numpy.maximum((255 - c) * c2d, 1)
# precomputed values
c2d_tdn = tdn * c2d
m2_c2d_tdn = m2 * c2d_tdn
m2d_c2d_tdn = m2d * c2d_tdn
c2_m2d = c2 * m2d
# float vt = m2 + tvn * c2
rt = ((m2_c2d_tdn + trn * c2_m2d) // m2d_c2d_tdn).astype('B')
gt = ((m2_c2d_tdn + tgn * c2_m2d) // m2d_c2d_tdn).astype('B')
bt = ((m2_c2d_tdn + tbn * c2_m2d) // m2d_c2d_tdn).astype('B')
xt = numpy.column_stack((rt, gt, bt, a))
return self.__class__(rgba=xt.tobytes(), size=self._size)
@classmethod @classmethod
def load_from_file(cls, filename): def load_from_file(cls, filename):
@ -130,6 +174,12 @@ get_from_stream(), get_from_vm(), get_xdg_icon_from_vm(), get_through_dvm()'''
return cls(rgba=rgba, size=size) return cls(rgba=rgba, size=size)
def load_from_file_pil(cls, filename):
'''Loads image from local file using PIL.'''
img = PIL.Image.open(filename)
img = img.convert('RGBA')
return cls(rgba=img.tobytes(), size=img.size)
@classmethod @classmethod
def get_from_stream(cls, stream, max_width=MAX_WIDTH, max_height=MAX_HEIGHT): def get_from_stream(cls, stream, max_width=MAX_WIDTH, max_height=MAX_HEIGHT):
'''Carefully parse image data from stream. '''Carefully parse image data from stream.
@ -223,26 +273,23 @@ expects header+RGBA on stdin. This method is invoked from qvm-imgconverter-clien
def __ne__(self, other): def __ne__(self, other):
return not self.__eq__(other) return not self.__eq__(other)
def hex_to_float(colour, channels=3, depth=8): def hex_to_int(colour, channels=3, depth=1):
'''Convert hex colour definition to tuple of floats.''' '''Convert hex colour definition to tuple of ints.'''
if depth % 4 != 0: length = channels * depth * 2
raise NotImplementedError('depths not divisible by 4 are unsupported') step = depth * 2
length = channels * depth // 4
step = depth // 4
# get rid of '#' or '0x' in front of hex values # get rid of '#' or '0x' in front of hex values
colour = colour[-length:] colour = colour[-length:]
return tuple(int(colour[i:i+step], 0x10) / float(2**depth - 1) for i in range(0, length, step)) return tuple(int(colour[i:i+step], 0x10) for i in range(0, length, step))
def tint(src, dst, colour): def tint(src, dst, colour):
'''Tint image to reflect vm label. '''Tint image to reflect vm label.
src and dst may specify format, like png:aqq.gif''' src and dst may NOT specify ImageMagick format'''
Image.load_from_file(src).tint(colour).save(dst) Image.load_from_file_pil(src).tint(colour).save_pil(dst)
# vim: ft=python sw=4 ts=4 et # vim: ft=python sw=4 ts=4 et

View File

@ -35,6 +35,8 @@ Common Linux files for Qubes Dom0 and VM
Summary: Python package qubesimgconverter Summary: Python package qubesimgconverter
Requires: python Requires: python
Requires: pycairo Requires: pycairo
Requires: python2-pillow
Requires: python2-numpy
%description -n python2-qubesimgconverter %description -n python2-qubesimgconverter
Python package qubesimgconverter Python package qubesimgconverter
@ -48,6 +50,8 @@ Requires: pycairo
Requires: python3 Requires: python3
Requires: python3-cairo Requires: python3-cairo
%endif %endif
Requires: python3-pillow
Requires: python3-numpy
%description -n python3-qubesimgconverter %description -n python3-qubesimgconverter
Python package qubesimgconverter Python package qubesimgconverter