From f893420871d7cfe3164d865d5efb77a01e23d803 Mon Sep 17 00:00:00 2001 From: qubesuser Date: Wed, 8 Nov 2017 17:28:46 +0100 Subject: [PATCH 1/5] remove unused cairo import --- imgconverter/qubesimgconverter/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/imgconverter/qubesimgconverter/__init__.py b/imgconverter/qubesimgconverter/__init__.py index 15cd3b4..8d00507 100644 --- a/imgconverter/qubesimgconverter/__init__.py +++ b/imgconverter/qubesimgconverter/__init__.py @@ -35,7 +35,6 @@ import subprocess import sys import unittest -import cairo # 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 From 6c6070ab49dea24df89ac231d63a6f2c979e31a1 Mon Sep 17 00:00:00 2001 From: qubesuser Date: Wed, 8 Nov 2017 17:40:55 +0100 Subject: [PATCH 2/5] add Python pillow and numpy dependencies --- archlinux/PKGBUILD | 2 +- debian/control | 2 +- rpm_spec/qubes-utils.spec | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/archlinux/PKGBUILD b/archlinux/PKGBUILD index 20f8378..d81f8d9 100644 --- a/archlinux/PKGBUILD +++ b/archlinux/PKGBUILD @@ -45,7 +45,7 @@ make -C imgconverter all } 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 all for python2 diff --git a/debian/control b/debian/control index 692d960..33475e8 100644 --- a/debian/control +++ b/debian/control @@ -17,7 +17,7 @@ Vcs-Git: http://dsg.is/qubes/qubes-linux-utils.git Package: qubes-utils 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 Breaks: qubes-core-agent (<< 3.1.4) Recommends: python2.7 diff --git a/rpm_spec/qubes-utils.spec b/rpm_spec/qubes-utils.spec index 6babc51..46843a0 100644 --- a/rpm_spec/qubes-utils.spec +++ b/rpm_spec/qubes-utils.spec @@ -35,6 +35,8 @@ Common Linux files for Qubes Dom0 and VM Summary: Python package qubesimgconverter Requires: python Requires: pycairo +Requires: python2-pillow +Requires: python2-numpy %description -n python2-qubesimgconverter Python package qubesimgconverter @@ -48,6 +50,8 @@ Requires: pycairo Requires: python3 Requires: python3-cairo %endif +Requires: python3-pillow +Requires: python3-numpy %description -n python3-qubesimgconverter Python package qubesimgconverter From 86e9231ac97606980c311504a3d0ecf4c9c73c7c Mon Sep 17 00:00:00 2001 From: qubesuser Date: Wed, 8 Nov 2017 17:29:25 +0100 Subject: [PATCH 3/5] use PIL image library instead of ImageMagick to load/save images when tinting --- imgconverter/qubesimgconverter/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/imgconverter/qubesimgconverter/__init__.py b/imgconverter/qubesimgconverter/__init__.py index 8d00507..9b26a60 100644 --- a/imgconverter/qubesimgconverter/__init__.py +++ b/imgconverter/qubesimgconverter/__init__.py @@ -35,6 +35,7 @@ import subprocess import sys import unittest +import PIL.Image # 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 @@ -72,6 +73,12 @@ get_from_stream(), get_from_vm(), get_xdg_icon_from_vm(), get_through_dvm()''' if p.wait(): 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 def data(self): return self._rgba @@ -129,6 +136,12 @@ get_from_stream(), get_from_vm(), get_xdg_icon_from_vm(), get_through_dvm()''' 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 def get_from_stream(cls, stream, max_width=MAX_WIDTH, max_height=MAX_HEIGHT): '''Carefully parse image data from stream. @@ -239,9 +252,9 @@ def hex_to_float(colour, channels=3, depth=8): def tint(src, dst, colour): '''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 From 843ac6c47766320c033360c0af53755c9cca6408 Mon Sep 17 00:00:00 2001 From: qubesuser Date: Wed, 8 Nov 2017 17:29:58 +0100 Subject: [PATCH 4/5] reimplement tint algorithm with numpy for reasonable performance The old algorithm was so slow it would take seconds to tint all images for a VM. --- imgconverter/qubesimgconverter/__init__.py | 88 ++++++++++++++-------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/imgconverter/qubesimgconverter/__init__.py b/imgconverter/qubesimgconverter/__init__.py index 9b26a60..dafabe7 100644 --- a/imgconverter/qubesimgconverter/__init__.py +++ b/imgconverter/qubesimgconverter/__init__.py @@ -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. -import colorsys -import math import os import re try: @@ -36,6 +34,7 @@ import sys import unittest import PIL.Image +import numpy # 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 @@ -94,27 +93,59 @@ get_from_stream(), get_from_vm(), get_xdg_icon_from_vm(), get_through_dvm()''' def tint(self, colour): '''Return new tinted image''' - r, g, b = hex_to_float(colour) - h, _, s = colorsys.rgb_to_hls(r, g, b) - result = BytesIO() - - # duplicate the whole loop for performance reasons - if sys.version_info[0] < 3: - for i in range(0, self._size[0] * self._size[1] * 4, 4): - r, g, b, a = tuple(ord(c) / 255. for c in self._rgba[i:i+4]) - _, l, _ = colorsys.rgb_to_hls(r, g, b) - r, g, b = colorsys.hls_to_rgb(h, l, s) - - result.write(b''.join(chr(int(i * 255)) for i in [r, g, b, a])) + tr, tg, tb = hex_to_int(colour) + tM = max(tr, tg, tb) + tm = min(tr, tg, tb) + tl2 = tM + tm + + # (trn/tdn, tgn/tdn, tbn/tdn) is the tint color with lightness set to 0.5 + if tl2 == 0 or tl2 == 510: # avoid division by 0 + tdn = 2 + trn = 1 + tgn = 1 + tbn = 1 + elif tl2 <= 255: + tdn = tl2 + trn = tr + tgn = tg + tbn = tb else: - for i in range(0, self._size[0] * self._size[1] * 4, 4): - r, g, b, a = tuple(c / 255. for c in self._rgba[i:i + 4]) - _, l, _ = colorsys.rgb_to_hls(r, g, b) - r, g, b = colorsys.hls_to_rgb(h, l, s) - - result.write(bytes(int(i * 255) for i in [r, g, b, a])) - - return self.__class__(rgba=result.getvalue(), size=self._size) + tdn = 510 - tl2 + trn = tdn - (255 - tr) + tgn = tdn - (255 - tg) + tbn = tdn - (255 - tb) + + # (trni/tdn, tgni/tdn, tbni/tdn) is the inverted tint color with lightness set to 0.5 + trni = tdn - trn + tgni = tdn - tgn + tbni = tdn - tbn + + tdn255 = tdn * 255 + + # 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) + m = numpy.minimum(numpy.minimum(r, g), b) + + # l2 is the lightness of the pixel in the original image in 0-510 range + l2 = M.astype('u4') + m.astype('u4') + l2i = 510 - l2 + l2low = l2 <= 255 + + # change lightness of tint color to lightness of image pixel + # if l2 is low, just multiply tint color with 0.5 lightness by pixel lightness + # else, invert tint color, multiply by inverted pixel lightness, then invert again + rt = (numpy.select([l2low, True], [l2 * trn, tdn255 - l2i * trni]) // tdn).astype('B') + gt = (numpy.select([l2low, True], [l2 * tgn, tdn255 - l2i * tgni]) // tdn).astype('B') + bt = (numpy.select([l2low, True], [l2 * tbn, tdn255 - l2i * tbni]) // tdn).astype('B') + + xt = numpy.column_stack((rt, gt, bt, a)) + return self.__class__(rgba=xt.tobytes(), size=self._size) @classmethod def load_from_file(cls, filename): @@ -235,19 +266,16 @@ expects header+RGBA on stdin. This method is invoked from qvm-imgconverter-clien def __ne__(self, other): return not self.__eq__(other) -def hex_to_float(colour, channels=3, depth=8): - '''Convert hex colour definition to tuple of floats.''' - - if depth % 4 != 0: - raise NotImplementedError('depths not divisible by 4 are unsupported') +def hex_to_int(colour, channels=3, depth=1): + '''Convert hex colour definition to tuple of ints.''' - length = channels * depth // 4 - step = depth // 4 + length = channels * depth * 2 + step = depth * 2 # get rid of '#' or '0x' in front of hex values 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): '''Tint image to reflect vm label. From ee58088decddce0cf692149ba81dff3397bd0c0a Mon Sep 17 00:00:00 2001 From: qubesuser Date: Wed, 8 Nov 2017 17:31:24 +0100 Subject: [PATCH 5/5] replace tinting algorithm with one that partially preserves saturation too This algorithm partially preserves saturation, for a better result, but enforces a minimum chroma, so that greyscale images get tinted. --- imgconverter/qubesimgconverter/__init__.py | 75 ++++++++++++---------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/imgconverter/qubesimgconverter/__init__.py b/imgconverter/qubesimgconverter/__init__.py index dafabe7..40a5499 100644 --- a/imgconverter/qubesimgconverter/__init__.py +++ b/imgconverter/qubesimgconverter/__init__.py @@ -96,31 +96,18 @@ get_from_stream(), get_from_vm(), get_xdg_icon_from_vm(), get_through_dvm()''' tr, tg, tb = hex_to_int(colour) tM = max(tr, tg, tb) tm = min(tr, tg, tb) - tl2 = tM + tm - # (trn/tdn, tgn/tdn, tbn/tdn) is the tint color with lightness set to 0.5 - if tl2 == 0 or tl2 == 510: # avoid division by 0 - tdn = 2 + # (trn/tdn, tgn/tdn, tbn/tdn) is the tint color with maximum saturation + if tm == tM: trn = 1 tgn = 1 tbn = 1 - elif tl2 <= 255: - tdn = tl2 - trn = tr - tgn = tg - tbn = tb + tdn = 2 else: - tdn = 510 - tl2 - trn = tdn - (255 - tr) - tgn = tdn - (255 - tg) - tbn = tdn - (255 - tb) - - # (trni/tdn, tgni/tdn, tbni/tdn) is the inverted tint color with lightness set to 0.5 - trni = tdn - trn - tgni = tdn - tgn - tbni = tdn - tbn - - tdn255 = tdn * 255 + trn = tr - tm + tgn = tg - tm + tbn = tb - tm + tdn = tM - tm # use a 1D image representation since we only process a single pixel at a time pixels = self._size[0] * self._size[1] @@ -129,20 +116,40 @@ get_from_stream(), get_from_vm(), get_xdg_icon_from_vm(), get_through_dvm()''' g = x[:, 1] b = x[:, 2] a = x[:, 3] - M = numpy.maximum(numpy.maximum(r, g), b) - m = numpy.minimum(numpy.minimum(r, g), b) - - # l2 is the lightness of the pixel in the original image in 0-510 range - l2 = M.astype('u4') + m.astype('u4') - l2i = 510 - l2 - l2low = l2 <= 255 - - # change lightness of tint color to lightness of image pixel - # if l2 is low, just multiply tint color with 0.5 lightness by pixel lightness - # else, invert tint color, multiply by inverted pixel lightness, then invert again - rt = (numpy.select([l2low, True], [l2 * trn, tdn255 - l2i * trni]) // tdn).astype('B') - gt = (numpy.select([l2low, True], [l2 * tgn, tdn255 - l2i * tgni]) // tdn).astype('B') - bt = (numpy.select([l2low, True], [l2 * tbn, tdn255 - l2i * tbni]) // tdn).astype('B') + M = numpy.maximum(numpy.maximum(r, g), b).astype('u4') + m = numpy.minimum(numpy.minimum(r, g), b).astype('u4') + + # 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)