[gnome-music/wip/jfelder/album-cover-draw] albumwidget: Album art cover background



commit 8d6fa755d23d82ad787a36f08e0ada309dbb0693
Author: Jean Felder <jfelder src gnome org>
Date:   Tue May 29 20:28:17 2018 +0200

    albumwidget: Album art cover background
    
    Display a blurred version of the album art cover as background. Labels
    color are updated (black or white) to be visible with this new
    background.
    
    Closes: #10

 data/org.gnome.Music.css          |  8 ++++
 data/ui/AlbumWidget.ui            |  4 +-
 gnomemusic/albumartcache.py       | 67 +++++++++++++++++++++++++++++
 gnomemusic/utils.py               | 90 +++++++++++++++++++++++++++++++++++++++
 gnomemusic/widgets/albumwidget.py | 48 ++++++++++++++++++++-
 5 files changed, 214 insertions(+), 3 deletions(-)
---
diff --git a/data/org.gnome.Music.css b/data/org.gnome.Music.css
index f5ea3b7b..89985425 100644
--- a/data/org.gnome.Music.css
+++ b/data/org.gnome.Music.css
@@ -94,3 +94,11 @@ box#ArtistAlbumsWidget .artist-label {
 .tooltip-title {
   font-weight: bold;
 }
+
+.black {
+    color: black;
+}
+
+.white {
+    color: white;
+}
diff --git a/data/ui/AlbumWidget.ui b/data/ui/AlbumWidget.ui
index 9dd7253d..e8fde2ab 100644
--- a/data/ui/AlbumWidget.ui
+++ b/data/ui/AlbumWidget.ui
@@ -9,7 +9,7 @@
       <class name="content-view"/>
     </style>
     <child>
-      <object class="GtkBox">
+      <object class="GtkBox" id="_main_box">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
         <child>
@@ -23,7 +23,7 @@
             <property name="margin_bottom">32</property>
             <property name="vexpand">True</property>
             <child>
-              <object class="GtkBox" id="albumDetails">
+              <object class="GtkBox" id="_album_details">
                 <property name="visible">True</property>
                 <property name="can_focus">False</property>
                 <property name="halign">center</property>
diff --git a/gnomemusic/albumartcache.py b/gnomemusic/albumartcache.py
index 27ce2ec4..19f9242e 100644
--- a/gnomemusic/albumartcache.py
+++ b/gnomemusic/albumartcache.py
@@ -28,6 +28,7 @@ from math import pi
 import os
 
 import cairo
+from PIL import Image, ImageFilter
 import gi
 gi.require_version('GstTag', '1.0')
 gi.require_version('MediaArt', '2.0')
@@ -169,6 +170,7 @@ class Art(GObject.GObject):
         MEDIUM = (128, 128)
         LARGE = (256, 256)
         XLARGE = (512, 512)
+        XXLARGE = (1024, 1024)
 
         def __init__(self, width, height):
             """Intialize width and height"""
@@ -188,6 +190,10 @@ class Art(GObject.GObject):
         self._surface = None
         self._scale = scale
 
+        self._blurred_surface = None
+        self._blurred_size = None
+        self._label_color = None
+
     @log
     def lookup(self):
         """Starts the art lookup sequence"""
@@ -304,6 +310,67 @@ class Art(GObject.GObject):
 
         return self._surface
 
+    @log
+    def get_blurred_surface(self, width, height):
+        """Compute the blurred cairo surface of an ArtImage.
+
+        self._surface is resized and then blurred with a gaussian
+        filter. The dominant color of the blurred image is extracted
+        to detect which color (white or black) can be displayed on this
+        surface.
+        :param int width: requested width surface
+        :param int height: requested height surface
+        :returns: blurred cairo surface and foreground color
+        :rtype: (cairo.surface, Gdk.RGBA)
+        """
+        if not self._surface:
+            return None, None
+
+        size = (self._surface.get_width(), self._surface.get_height())
+        full_size = (width, height)
+
+        if (self._blurred_surface
+                and self._blurred_size[0] >= full_size[0]
+                and self._blurred_size[1] >= full_size[1]):
+            return self._blurred_surface, self._label_color
+
+        # convert cairo surface to a pillow image
+        img = Image.frombuffer(
+            "RGBA", size, self._surface.get_data(), "raw", "RGBA", 0, 1)
+
+        # resize and blur the image
+        ratio = full_size[0] / full_size[1]
+        h = int((1 / ratio) * full_size[1])
+        diff = full_size[1] - h
+        img_cropped = img.crop(
+            (0, diff // 2, size[0], size[1] - diff // 2))
+        img_scaled = img_cropped.resize(full_size, Image.BICUBIC)
+        img_blurred = img_scaled.filter(ImageFilter.GaussianBlur(30))
+
+        # convert the image to a cairo suface
+        arr = bytearray(img_blurred.tobytes('raw', 'RGBA'))
+        self._blurred_surface = cairo.ImageSurface.create_for_data(
+            arr, cairo.FORMAT_ARGB32, img_blurred.width, img_blurred.height)
+
+        self._blurred_size = (
+            self._blurred_surface.get_width(),
+            self._blurred_surface.get_height()
+        )
+
+        # compute dominant color of the blurred image to update
+        # foreground color in white or black
+        b, g, r, a = img_blurred.split()
+        img_blurred_rgb = Image.merge('RGB', (r, g, b))
+        dominant_color = utils.dominant_color(img_blurred_rgb)
+        white_ratio = utils.contrast_ratio(*dominant_color, 1., 1., 1.)
+        black_ratio = utils.contrast_ratio(*dominant_color, 0., 0., 0.)
+        if white_ratio > black_ratio:
+            self._label_color = Gdk.RGBA(1.0, 1.0, 1.0, 1.0)
+        else:
+            self._label_color = Gdk.RGBA(0.0, 0.0, 0.0, 0.0)
+
+        return self._blurred_surface, self._label_color
+
 
 class Cache(GObject.GObject):
     """Handles retrieval of MediaArt cache art
diff --git a/gnomemusic/utils.py b/gnomemusic/utils.py
index ec171663..ba63ee4d 100644
--- a/gnomemusic/utils.py
+++ b/gnomemusic/utils.py
@@ -22,7 +22,10 @@
 # code, but you are not obligated to do so.  If you do not wish to do so,
 # delete this exception statement from your version.
 
+import colorsys
 from enum import IntEnum
+from math import pow
+from PIL import Image
 
 from gettext import gettext as _
 
@@ -114,3 +117,90 @@ def seconds_to_string(duration):
     seconds %= 60
 
     return '{:d}:{:02d}'.format(minutes, seconds)
+
+
+def relative_luminance(r, g, b):
+    """Compute relative luminance of an RGB color.
+
+    Relative luminance is the relative brightness of any point in a
+    colorspace, normalized to 0 for darkest black and 1 for lightest
+    white.
+    See: https://www.w3.org/TR/WCAG20/#relativeluminancedef
+    :param float r: r channel between 0. and 1.
+    :param float g: g channel between 0. and 1.
+    :param float b: b channel between 0. and 1.
+    :returns: relative luminance
+    :rtype: float
+    """
+    params = []
+    for channel in [r, g, b]:
+        if channel <= 0.03928:
+            value = channel / 12.92
+        else:
+            value = pow((channel + 0.055) / 1.055, 2.4)
+        params.append(value)
+
+    luminance = 0.2126 * params[0] + 0.7152 * params[1] + 0.0722 * params[2]
+    return luminance
+
+
+def contrast_ratio(r1, g1, b1, r2, g2, b2):
+    """Compute contrast ratio between two RGB colors.
+
+    Contrat ratio is a measure to indicate how readable the color
+    combination is.
+    See: https://www.w3.org/TR/WCAG20/#contrast-ratiodef
+    :param float r1: r channel from color 1 between 0. and 1.
+    :param float g1: g channel from color 1 between 0. and 1.
+    :param float b1: b channel from color 1 between 0. and 1.
+    :param float r2: r channel from color 2 between 0. and 1.
+    :param float g2: g channel from color 2 between 0. and 1.
+    :param float b2: b channel from color 2 between 0. and 1.
+    :returns: constrat ratio
+    :rtype: float
+    """
+    l1 = relative_luminance(r1, g1, b1)
+    l2 = relative_luminance(r2, g2, b2)
+    l_max = max(l1, l2)
+    l_min = min(l1, l2)
+    return (l_max + 0.05) / (l_min + 0.05)
+
+
+def dominant_color(image):
+    """Compute dominant color of a pillow image.
+
+    Compute dominant color by extracting the peak of the hue channel of
+    the image's histogram.
+    :param Image image: a pillow image
+    :returns: dominant color
+    :rtype: (r, b, g) between 0. and 1.
+
+    """
+    # reduce image size if necessary to minimize computation time
+    if (image.width > 256
+            or image.height > 256):
+        image.thumbnail((256, 256), Image.ANTIALIAS)
+
+    im_hsv = image.convert('HSV')
+    histogram = im_hsv.histogram()[:256]
+    h_max = max(histogram)
+    h_max_value = histogram.index(h_max)
+
+    px = im_hsv.load()
+    width = image.size[0]
+    height = image.size[1]
+
+    s_max_value = 0
+    v_max_value = 0
+    for i in range(width):
+        for j in range(height):
+            pixel = px[i, j]
+            if pixel[0] == h_max_value:
+                s_max_value += pixel[1]
+                v_max_value += pixel[2]
+
+    s_max_value /= h_max
+    v_max_value /= h_max
+    r, g, b = colorsys.hsv_to_rgb(
+        h_max_value / 255.0, s_max_value / 255.0, v_max_value / 255.0)
+    return r, g, b
diff --git a/gnomemusic/widgets/albumwidget.py b/gnomemusic/widgets/albumwidget.py
index ecdc2c93..61003dfe 100644
--- a/gnomemusic/widgets/albumwidget.py
+++ b/gnomemusic/widgets/albumwidget.py
@@ -23,7 +23,7 @@
 # delete this exception statement from your version.
 
 from gettext import ngettext
-from gi.repository import GdkPixbuf, GObject, Gtk
+from gi.repository import Gdk, GdkPixbuf, GObject, Gtk
 
 from gnomemusic import log
 from gnomemusic.albumartcache import Art
@@ -45,11 +45,13 @@ class AlbumWidget(Gtk.EventBox):
 
     __gtype_name__ = 'AlbumWidget'
 
+    _album_details = Gtk.Template.Child()
     _artist_label = Gtk.Template.Child()
     _composer_label = Gtk.Template.Child()
     _composer_info_label = Gtk.Template.Child()
     _cover_stack = Gtk.Template.Child()
     _disc_listbox = Gtk.Template.Child()
+    _main_box = Gtk.Template.Child()
     _released_info_label = Gtk.Template.Child()
     _running_info_label = Gtk.Template.Child()
     _title_label = Gtk.Template.Child()
@@ -81,6 +83,8 @@ class AlbumWidget(Gtk.EventBox):
         self._create_model()
         self._album_name = None
 
+        self._window_draw_handler = None
+
         self.bind_property(
             'selection-mode', self._disc_listbox, 'selection-mode',
             GObject.BindingFlags.BIDIRECTIONAL)
@@ -130,6 +134,16 @@ class AlbumWidget(Gtk.EventBox):
         self._album_name = utils.get_album_title(album)
         artist = utils.get_artist_name(album)
 
+        self._set_foreground_color(Gdk.RGBA(0.0, 0.0, 0.0, 0.0))
+        if self._window_draw_handler:
+            self._main_box.disconnect(self._window_draw_handler)
+        self._window_draw_handler = None
+        self._foreground_color = None
+        self._art_background = Art(
+            Art.Size.XXLARGE, album, self.props.scale_factor)
+        self._art_background.connect('finished', self._set_background)
+        self._art_background.lookup()
+
         self._title_label.props.label = self._album_name
         self._title_label.props.tooltip_text = self._album_name
 
@@ -186,6 +200,38 @@ class AlbumWidget(Gtk.EventBox):
 
         return disc_box
 
+    @log
+    def _set_foreground_color(self, color):
+        remove_color = "black"
+        add_color = "white"
+        if color == Gdk.RGBA(0.0, 0.0, 0.0, 0.0):
+            remove_color = "white"
+            add_color = "black"
+
+        for widget in [self._album_details, self._disc_listbox]:
+            style_ctx = widget.get_style_context()
+            style_ctx.remove_class(remove_color)
+            style_ctx.add_class(add_color)
+
+    @log
+    def _set_background(self, klass):
+        self._window_draw_handler = self._main_box.connect(
+            "draw", self._draw_background, klass)
+
+    @log
+    def _draw_background(self, widget, ctx, klass):
+        width = self.get_allocated_width()
+        height = self.get_allocated_height()
+        background_surface, fg_color = klass.get_blurred_surface(width, height)
+
+        if not self._foreground_color:
+            self._foreground_color = fg_color
+            self._set_foreground_color(fg_color)
+
+        ctx.set_source_surface(background_surface)
+        ctx.rectangle(0, 0, width, height)
+        ctx.fill()
+
     @log
     def _song_activated(self, widget, song_widget):
         if self.props.selection_mode:


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]