# # Copyright (c) 2009-2023 Tom Keffer # # See the file LICENSE.txt for your full rights. # """Routines for generating image plots.""" import colorsys import locale import os import time from PIL import Image, ImageDraw, ImageFont import weeplot.utilities import weeutil.weeutil from weeplot.utilities import tobgr from weeutil.weeutil import max_with_none, min_with_none, to_bool, option_as_list # Test if this version of Pillow has ImageFont.getbbox. If not, we will activate a workaround. try: ImageFont.ImageFont.getbbox except AttributeError: PIL_HAS_BBOX = False else: PIL_HAS_BBOX = True class GeneralPlot(object): """Holds various parameters necessary for a plot. It should be specialized by the type of plot. """ def __init__(self, plot_dict): """Initialize an instance of GeneralPlot. plot_dict: an instance of ConfigObj, or something that looks like it. """ self.line_list = [] self.xscale = (None, None, None) self.yscale = (None, None, None) self.anti_alias = int(plot_dict.get('anti_alias', 1)) self.image_width = int(plot_dict.get('image_width', 300)) * self.anti_alias self.image_height = int(plot_dict.get('image_height', 180)) * self.anti_alias self.image_background_color = tobgr(plot_dict.get('image_background_color', '0xf5f5f5')) self.chart_background_color = tobgr(plot_dict.get('chart_background_color', '0xd8d8d8')) self.chart_gridline_color = tobgr(plot_dict.get('chart_gridline_color', '0xa0a0a0')) color_list = option_as_list(plot_dict.get('chart_line_colors', ['0xff0000', '0x00ff00', '0x0000ff'])) fill_color_list = option_as_list(plot_dict.get('chart_fill_colors', color_list)) width_list = option_as_list(plot_dict.get('chart_line_width', [1, 1, 1])) self.chart_line_colors = [tobgr(v) for v in color_list] self.chart_fill_colors = [tobgr(v) for v in fill_color_list] self.chart_line_widths = [int(v) for v in width_list] self.top_label_font_path = plot_dict.get('top_label_font_path') self.top_label_font_size = int(plot_dict.get('top_label_font_size', 10)) * self.anti_alias self.unit_label = None self.unit_label_font_path = plot_dict.get('unit_label_font_path') self.unit_label_font_color = tobgr(plot_dict.get('unit_label_font_color', '0x000000')) self.unit_label_font_size = int(plot_dict.get('unit_label_font_size', 10)) * self.anti_alias self.unit_label_position = (10 * self.anti_alias, 0) self.bottom_label = u"" self.bottom_label_font_path = plot_dict.get('bottom_label_font_path') self.bottom_label_font_color= tobgr(plot_dict.get('bottom_label_font_color', '0x000000')) self.bottom_label_font_size = int(plot_dict.get('bottom_label_font_size', 10)) * self.anti_alias self.bottom_label_offset = int(plot_dict.get('bottom_label_offset', 3)) self.axis_label_font_path = plot_dict.get('axis_label_font_path') self.axis_label_font_color = tobgr(plot_dict.get('axis_label_font_color', '0x000000')) self.axis_label_font_size = int(plot_dict.get('axis_label_font_size', 10)) * self.anti_alias # Make sure the formats used for the x- and y-axes are in unicode. self.x_label_format = plot_dict.get('x_label_format') self.y_label_format = plot_dict.get('y_label_format') self.x_nticks = int(plot_dict.get('x_nticks', 10)) self.y_nticks = int(plot_dict.get('y_nticks', 10)) self.x_label_spacing = int(plot_dict.get('x_label_spacing', 2)) self.y_label_spacing = int(plot_dict.get('y_label_spacing', 2)) # Calculate sensible margins for the given image and font sizes. self.y_label_side = plot_dict.get('y_label_side', 'left') if self.y_label_side == 'left' or self.y_label_side == 'both': self.lmargin = int(4.0 * self.axis_label_font_size) else: self.lmargin = 20 * self.anti_alias if self.y_label_side == 'right' or self.y_label_side == 'both': self.rmargin = int(4.0 * self.axis_label_font_size) else: self.rmargin = 20 * self.anti_alias self.bmargin = int(1.5 * (self.bottom_label_font_size + self.axis_label_font_size) + 0.5) self.tmargin = int(1.5 * self.top_label_font_size + 0.5) self.tbandht = int(1.2 * self.top_label_font_size + 0.5) self.padding = 3 * self.anti_alias self.render_rose = False self.rose_width = int(plot_dict.get('rose_width', 21)) self.rose_height = int(plot_dict.get('rose_height', 21)) self.rose_diameter = int(plot_dict.get('rose_diameter', 10)) self.rose_position = (self.lmargin + self.padding + 5, self.image_height - self.bmargin - self.padding - self.rose_height) self.rose_rotation = None self.rose_label = plot_dict.get('rose_label', 'N') self.rose_label_font_path = plot_dict.get('rose_label_font_path', self.bottom_label_font_path) self.rose_label_font_size = int(plot_dict.get('rose_label_font_size', 10)) self.rose_label_font_color = tobgr(plot_dict.get('rose_label_font_color', '0x000000')) self.rose_line_width = int(plot_dict.get('rose_line_width', 1)) self.rose_color = plot_dict.get('rose_color') if self.rose_color is not None: self.rose_color = tobgr(self.rose_color) # Show day/night transitions self.show_daynight = to_bool(plot_dict.get('show_daynight', False)) self.daynight_day_color = tobgr(plot_dict.get('daynight_day_color', '0xffffff')) self.daynight_night_color = tobgr(plot_dict.get('daynight_night_color', '0xf0f0f0')) self.daynight_edge_color = tobgr(plot_dict.get('daynight_edge_color', '0xefefef')) self.daynight_gradient = int(plot_dict.get('daynight_gradient', 20)) # initialize the location self.latitude = None self.longitude = None # normalize the font paths relative to the skin directory skin_dir = plot_dict.get('skin_dir', '') self.top_label_font_path = self.normalize_path(skin_dir, self.top_label_font_path) self.bottom_label_font_path = self.normalize_path(skin_dir, self.bottom_label_font_path) self.unit_label_font_path = self.normalize_path(skin_dir, self.unit_label_font_path) self.axis_label_font_path = self.normalize_path(skin_dir, self.axis_label_font_path) self.rose_label_font_path = self.normalize_path(skin_dir, self.rose_label_font_path) @staticmethod def normalize_path(skin_dir, path): if path is None: return None return os.path.join(skin_dir, path) def setBottomLabel(self, bottom_label): """Set the label to be put at the bottom of the plot. """ self.bottom_label = bottom_label def setUnitLabel(self, unit_label): """Set the label to be used to show the units of the plot. """ self.unit_label = unit_label def setXScaling(self, xscale): """Set the X scaling. xscale: A 3-way tuple (xmin, xmax, xinc) """ self.xscale = xscale def setYScaling(self, yscale): """Set the Y scaling. yscale: A 3-way tuple (ymin, ymax, yinc) """ self.yscale = yscale def addLine(self, line): """Add a line to be plotted. line: an instance of PlotLine """ if None in line.x: raise weeplot.ViolatedPrecondition("X vector cannot have any values 'None' ") self.line_list.append(line) def setLocation(self, lat, lon): self.latitude = lat self.longitude = lon def setDayNight(self, showdaynight, daycolor, nightcolor, edgecolor): """Configure day/night bands. showdaynight: Boolean flag indicating whether to draw day/night bands daycolor: color for day bands nightcolor: color for night bands edgecolor: color for transition between day and night """ self.show_daynight = showdaynight self.daynight_day_color = daycolor self.daynight_night_color = nightcolor self.daynight_edge_color = edgecolor def render(self): """Traverses the universe of things that have to be plotted in this image, rendering them and returning the results as a new Image object. """ # NB: In what follows the variable 'draw' is an instance of an ImageDraw object and is in pixel units. # The variable 'sdraw' is an instance of ScaledDraw and its units are in the "scaled" units of the plot # (e.g., the horizontal scaling might be for seconds, the vertical for degrees Fahrenheit.) image = Image.new("RGB", (self.image_width, self.image_height), self.image_background_color) draw = ImageDraw.ImageDraw(image) draw.rectangle(((self.lmargin,self.tmargin), (self.image_width - self.rmargin, self.image_height - self.bmargin)), fill=self.chart_background_color) self._renderBottom(draw) self._renderTopBand(draw) self._calcXScaling() self._calcYScaling() self._calcXLabelFormat() self._calcYLabelFormat() sdraw = self._getScaledDraw(draw) if self.show_daynight: self._renderDayNight(sdraw) self._renderXAxes(sdraw) self._renderYAxes(sdraw) self._renderPlotLines(sdraw) if self.render_rose: self._renderRose(image, draw) if self.anti_alias != 1: image.thumbnail((self.image_width / self.anti_alias, self.image_height / self.anti_alias), Image.LANCZOS) return image def _getScaledDraw(self, draw): """Returns an instance of ScaledDraw, with the appropriate scaling. draw: An instance of ImageDraw """ sdraw = weeplot.utilities.ScaledDraw( draw, ( (self.lmargin + self.padding, self.tmargin + self.padding), (self.image_width - self.rmargin - self.padding, self.image_height - self.bmargin - self.padding) ), ( (self.xscale[0], self.yscale[0]), (self.xscale[1], self.yscale[1]) ) ) return sdraw def _renderDayNight(self, sdraw): """Draw vertical bands for day/night.""" (first, transitions) = weeutil.weeutil.getDayNightTransitions( self.xscale[0], self.xscale[1], self.latitude, self.longitude) color = self.daynight_day_color \ if first == 'day' else self.daynight_night_color xleft = self.xscale[0] for x in transitions: sdraw.rectangle(((xleft,self.yscale[0]), (x,self.yscale[1])), fill=color) xleft = x color = self.daynight_night_color \ if color == self.daynight_day_color else self.daynight_day_color sdraw.rectangle(((xleft,self.yscale[0]), (self.xscale[1],self.yscale[1])), fill=color) if self.daynight_gradient: if first == 'day': color1 = self.daynight_day_color color2 = self.daynight_night_color else: color1 = self.daynight_night_color color2 = self.daynight_day_color nfade = self.daynight_gradient # gradient is longer at the poles than the equator d = 120 + 300 * (1 - (90.0 - abs(self.latitude)) / 90.0) for i in range(len(transitions)): last_ = self.xscale[0] if i == 0 else transitions[i-1] next_ = transitions[i+1] if i < len(transitions)-1 else self.xscale[1] for z in range(1,nfade): c = blend_hls(color2, color1, float(z)/float(nfade)) rgbc = int2rgbstr(c) x1 = transitions[i]-d*(nfade+1)/2+d*z if last_ < x1 < next_: sdraw.rectangle(((x1, self.yscale[0]), (x1+d, self.yscale[1])), fill=rgbc) if color1 == self.daynight_day_color: color1 = self.daynight_night_color color2 = self.daynight_day_color else: color1 = self.daynight_day_color color2 = self.daynight_night_color # draw a line at the actual sunrise/sunset for x in transitions: sdraw.line((x,x),(self.yscale[0],self.yscale[1]), fill=self.daynight_edge_color) def _renderXAxes(self, sdraw): """Draws the x axis and vertical constant-x lines, as well as the labels. """ axis_label_font = weeplot.utilities.get_font_handle(self.axis_label_font_path, self.axis_label_font_size) drawlabelcount = 0 for x in weeutil.weeutil.stampgen(self.xscale[0], self.xscale[1], self.xscale[2]) : sdraw.line((x, x), (self.yscale[0], self.yscale[1]), fill=self.chart_gridline_color, width=self.anti_alias) if drawlabelcount % self.x_label_spacing == 0 : xlabel = self._genXLabel(x) if PIL_HAS_BBOX: axis_label_width = sdraw.draw.textlength(xlabel, font=axis_label_font) else: axis_label_width, _ = sdraw.draw.textsize(xlabel, font=axis_label_font) xpos = sdraw.xtranslate(x) sdraw.draw.text((xpos - axis_label_width/2, self.image_height - self.bmargin + 2), xlabel, fill=self.axis_label_font_color, font=axis_label_font) drawlabelcount += 1 def _renderYAxes(self, sdraw): """Draws the y axis and horizontal constant-y lines, as well as the labels. Should be sufficient for most purposes. """ nygridlines = int((self.yscale[1] - self.yscale[0]) / self.yscale[2] + 1.5) axis_label_font = weeplot.utilities.get_font_handle(self.axis_label_font_path, self.axis_label_font_size) # Draw the (constant y) grid lines for i in range(nygridlines) : y = self.yscale[0] + i * self.yscale[2] sdraw.line((self.xscale[0], self.xscale[1]), (y, y), fill=self.chart_gridline_color, width=self.anti_alias) # Draw a label on every other line: if i % self.y_label_spacing == 0 : ylabel = self._genYLabel(y) if PIL_HAS_BBOX: left, top, right, bottom = axis_label_font.getbbox(ylabel) axis_label_width, axis_label_height = right - left, bottom - top else: axis_label_width, axis_label_height = sdraw.draw.textsize(ylabel, font=axis_label_font) ypos = sdraw.ytranslate(y) # We want to treat Truetype and bitmapped fonts the same. By default, Truetype # measures the top of the bounding box at the top of the ascender, while it's # the top of the text for bitmapped. Specify an anchor of "lt" (left, top) for # both. NB: argument "anchor" has been around at least as early as # Pillow V5.0 (2018), but was not implemented until V8.0.0. if self.y_label_side == 'left' or self.y_label_side == 'both': sdraw.draw.text((self.lmargin - axis_label_width - 2, ypos - axis_label_height/2), ylabel, fill=self.axis_label_font_color, font=axis_label_font, anchor="lt") if self.y_label_side == 'right' or self.y_label_side == 'both': sdraw.draw.text((self.image_width - self.rmargin + 4, ypos - axis_label_height/2), ylabel, fill=self.axis_label_font_color, font=axis_label_font, anchor="lt") def _renderPlotLines(self, sdraw): """Draw the collection of lines, using a different color for each one. Because there is a limited set of colors, they need to be recycled if there are very many lines. """ nlines = len(self.line_list) ncolors = len(self.chart_line_colors) nfcolors = len(self.chart_fill_colors) nwidths = len(self.chart_line_widths) # Draw them in reverse order, so the first line comes out on top of the image for j, this_line in enumerate(self.line_list[::-1]): iline=nlines-j-1 color = self.chart_line_colors[iline%ncolors] if this_line.color is None else this_line.color fill_color = self.chart_fill_colors[iline%nfcolors] if this_line.fill_color is None else this_line.fill_color width = (self.chart_line_widths[iline%nwidths] if this_line.width is None else this_line.width) * self.anti_alias # Calculate the size of a gap in data maxdx = None if this_line.line_gap_fraction is not None: maxdx = this_line.line_gap_fraction * (self.xscale[1] - self.xscale[0]) if this_line.plot_type == 'line': ms = this_line.marker_size if ms is not None: ms *= self.anti_alias sdraw.line(this_line.x, this_line.y, line_type=this_line.line_type, marker_type=this_line.marker_type, marker_size=ms, fill = color, width = width, maxdx = maxdx) elif this_line.plot_type == 'bar' : for x, y, bar_width in zip(this_line.x, this_line.y, this_line.bar_width): if y is None: continue sdraw.rectangle(((x - bar_width, self.yscale[0]), (x, y)), fill=fill_color, outline=color) elif this_line.plot_type == 'vector' : for (x, vec) in zip(this_line.x, this_line.y): sdraw.vector(x, vec, vector_rotate = this_line.vector_rotate, fill = color, width = width) self.render_rose = True self.rose_rotation = this_line.vector_rotate if self.rose_color is None: self.rose_color = color def _renderBottom(self, draw): """Draw anything at the bottom (just some text right now). """ bottom_label_font = weeplot.utilities.get_font_handle(self.bottom_label_font_path, self.bottom_label_font_size) if PIL_HAS_BBOX: left, top, right, bottom = bottom_label_font.getbbox(self.bottom_label) bottom_label_width, bottom_label_height = right - left, bottom - top else: bottom_label_width, bottom_label_height = draw.textsize(self.bottom_label, font=bottom_label_font) draw.text(((self.image_width - bottom_label_width)/2, self.image_height - bottom_label_height - self.bottom_label_offset), self.bottom_label, fill=self.bottom_label_font_color, font=bottom_label_font, anchor="lt") def _renderTopBand(self, draw): """Draw the top band and any text in it. """ # Draw the top band rectangle draw.rectangle(((0,0), (self.image_width, self.tbandht)), fill = self.chart_background_color) # Put the units in the upper left corner unit_label_font = weeplot.utilities.get_font_handle(self.unit_label_font_path, self.unit_label_font_size) if self.unit_label: if self.y_label_side == 'left' or self.y_label_side == 'both': draw.text(self.unit_label_position, self.unit_label, fill=self.unit_label_font_color, font=unit_label_font) if self.y_label_side == 'right' or self.y_label_side == 'both': unit_label_position_right = (self.image_width - self.rmargin + 4, 0) draw.text(unit_label_position_right, self.unit_label, fill=self.unit_label_font_color, font=unit_label_font) top_label_font = weeplot.utilities.get_font_handle(self.top_label_font_path, self.top_label_font_size) # The top label is the appended label_list. However, it has to be drawn in segments # because each label may be in a different color. For now, append them together to get # the total width top_label = u' '.join([line.label for line in self.line_list]) if PIL_HAS_BBOX: top_label_width= draw.textlength(top_label, font=top_label_font) else: top_label_width, _ = draw.textsize(top_label, font=top_label_font) x = (self.image_width - top_label_width)/2 y = 0 ncolors = len(self.chart_line_colors) for i, this_line in enumerate(self.line_list): color = self.chart_line_colors[i%ncolors] if this_line.color is None else this_line.color # Draw a label draw.text( (x,y), this_line.label, fill = color, font = top_label_font) # Now advance the width of the label we just drew, plus a space: if PIL_HAS_BBOX: label_width = draw.textlength(this_line.label + u' ', font= top_label_font) else: label_width, _ = draw.textsize(this_line.label + u' ', font= top_label_font) x += label_width def _renderRose(self, image, draw): """Draw a compass rose.""" rose_center_x = self.rose_width/2 + 1 rose_center_y = self.rose_height/2 + 1 barb_width = 3 barb_height = 3 # The background is all white with a zero alpha (totally transparent) rose_image = Image.new("RGBA", (self.rose_width, self.rose_height), (0x00, 0x00, 0x00, 0x00)) rose_draw = ImageDraw.Draw(rose_image) fill_color = add_alpha(self.rose_color) # Draw the arrow straight up (North). First the shaft: rose_draw.line( ((rose_center_x, 0), (rose_center_x, self.rose_height)), width = self.rose_line_width, fill = fill_color) # Now the left barb: rose_draw.line( ((rose_center_x - barb_width, barb_height), (rose_center_x, 0)), width = self.rose_line_width, fill = fill_color) # And the right barb: rose_draw.line( ((rose_center_x, 0), (rose_center_x + barb_width, barb_height)), width = self.rose_line_width, fill = fill_color) rose_draw.ellipse(((rose_center_x - self.rose_diameter/2, rose_center_y - self.rose_diameter/2), (rose_center_x + self.rose_diameter/2, rose_center_y + self.rose_diameter/2)), outline = fill_color) # Rotate if necessary: if self.rose_rotation: rose_image = rose_image.rotate(self.rose_rotation) rose_draw = ImageDraw.Draw(rose_image) # Calculate the position of the "N" label: rose_label_font = weeplot.utilities.get_font_handle(self.rose_label_font_path, self.rose_label_font_size) if PIL_HAS_BBOX: left, top, right, bottom = rose_label_font.getbbox(self.rose_label) rose_label_width, rose_label_height = right - left, bottom - top else: rose_label_width, rose_label_height = draw.textsize(self.rose_label, font=rose_label_font) # Draw the label in the middle of the (possibly) rotated arrow rose_draw.text((rose_center_x - rose_label_width/2 - 1, rose_center_y - rose_label_height/2 - 1), self.rose_label, fill=add_alpha(self.rose_label_font_color), font=rose_label_font, anchor="lt") # Paste the image of the arrow on to the main plot. The alpha # channel of the image will be used as the mask. # This will cause the arrow to overlay the background plot image.paste(rose_image, self.rose_position, rose_image) def _calcXScaling(self): """Calculates the x scaling. It will probably be specialized by plots where the x-axis represents time. """ (xmin, xmax) = self._calcXMinMax() self.xscale = weeplot.utilities.scale(xmin, xmax, self.xscale, nsteps=self.x_nticks) def _calcYScaling(self): """Calculates y scaling. Can be used 'as-is' for most purposes.""" # The filter is necessary because unfortunately the value 'None' is not # excluded from min and max (i.e., min(None, x) is not necessarily x). # The try block is necessary because min of an empty list throws a # ValueError exception. ymin = ymax = None for line in self.line_list: if line.plot_type == 'vector': try: # For progressive vector plots, we want the magnitude of the complex vector yline_max = max(abs(c) for c in [v for v in line.y if v is not None]) except ValueError: yline_max = None yline_min = - yline_max if yline_max is not None else None else: yline_min = min_with_none(line.y) yline_max = max_with_none(line.y) ymin = min_with_none([ymin, yline_min]) ymax = max_with_none([ymax, yline_max]) if ymin is None and ymax is None : # No valid data. Pick an arbitrary scaling self.yscale=(0.0, 1.0, 0.2) else: self.yscale = weeplot.utilities.scale(ymin, ymax, self.yscale, nsteps=self.y_nticks) def _calcXLabelFormat(self): if self.x_label_format is None: self.x_label_format = weeplot.utilities.pickLabelFormat(self.xscale[2]) def _calcYLabelFormat(self): if self.y_label_format is None: self.y_label_format = weeplot.utilities.pickLabelFormat(self.yscale[2]) def _genXLabel(self, x): xlabel = locale.format_string(self.x_label_format, x) return xlabel def _genYLabel(self, y): ylabel = locale.format_string(self.y_label_format, y) return ylabel def _calcXMinMax(self): xmin = xmax = None for line in self.line_list: xline_min = min_with_none(line.x) xline_max = max_with_none(line.x) # If the line represents a bar chart, then the actual minimum has to # be adjusted for the bar width of the first point if line.plot_type == 'bar': xline_min = xline_min - line.bar_width[0] xmin = min_with_none([xmin, xline_min]) xmax = max_with_none([xmax, xline_max]) return xmin, xmax class TimePlot(GeneralPlot) : """Class that specializes GeneralPlot for plots where the x-axis is time.""" def _calcXScaling(self): """Specialized version for time plots.""" if None in self.xscale: (xmin, xmax) = self._calcXMinMax() self.xscale = weeplot.utilities.scaletime(xmin, xmax) def _calcXLabelFormat(self): """Specialized version for time plots. Assumes that time is in unix epoch time.""" if self.x_label_format is None: (xmin, xmax) = self._calcXMinMax() if xmin is not None and xmax is not None: delta = xmax - xmin if delta > 30*24*3600: self.x_label_format = u"%x" elif delta > 24*3600: self.x_label_format = u"%x %X" else: self.x_label_format = u"%X" def _genXLabel(self, x): """Specialized version for time plots. Assumes that time is in unix epoch time.""" if self.x_label_format is None: return u'' time_tuple = time.localtime(x) # There are still some strftimes out there that don't support Unicode. try: xlabel = time.strftime(self.x_label_format, time_tuple) except UnicodeEncodeError: # Convert it to UTF8, then back again: xlabel = time.strftime(self.x_label_format.encode('utf-8'), time_tuple).decode('utf-8') return xlabel class PlotLine(object): """Represents a single line (or bar) in a plot. """ def __init__(self, x, y, label='', color=None, fill_color=None, width=None, plot_type='line', line_type='solid', marker_type=None, marker_size=10, bar_width=None, vector_rotate = None, line_gap_fraction=None): self.x = x self.y = y self.label = label self.plot_type = plot_type self.line_type = line_type self.marker_type = marker_type self.marker_size = marker_size self.color = color self.fill_color = fill_color self.width = width self.bar_width = bar_width self.vector_rotate = vector_rotate self.line_gap_fraction = line_gap_fraction def blend_hls(c, bg, alpha): """Fade from c to bg using alpha channel where 1 is solid and 0 is transparent. This fades across the hue, saturation, and lightness.""" return blend(c, bg, alpha, alpha, alpha) def blend_ls(c, bg, alpha): """Fade from c to bg where 1 is solid and 0 is transparent. Change only the lightness and saturation, not hue.""" return blend(c, bg, 1.0, alpha, alpha) def blend(c, bg, alpha_h, alpha_l, alpha_s): """Fade from c to bg in the hue, lightness, saturation colorspace. Added hue directionality to choose shortest circular hue path e.g. https://stackoverflow.com/questions/1416560/hsl-interpolation Also, grey detection to minimize colour wheel travel. Interesting resource: http://davidjohnstone.net/pages/lch-lab-colour-gradient-picker """ r1,g1,b1 = int2rgb(c) h1,l1,s1 = colorsys.rgb_to_hls(r1/255.0, g1/255.0, b1/255.0) r2,g2,b2 = int2rgb(bg) h2,l2,s2 = colorsys.rgb_to_hls(r2/255.0, g2/255.0, b2/255.0) # Check if either of the values is grey (saturation 0), # in which case don't needlessly reset hue to '0', reducing travel around colour wheel if s1 == 0: h1 = h2 if s2 == 0: h2 = h1 h_delta = h2 - h1 if abs(h_delta) > 0.5: # If interpolating over more than half-circle (0.5 radians) take shorter, opposite direction... h_range = 1.0 - abs(h_delta) h_dir = +1.0 if h_delta < 0.0 else -1.0 # Calculte h based on line back from h2 as proportion of h_range and alpha h = h2 - ( h_dir * h_range * alpha_h ) # Clamp h within 0.0 to 1.0 range h = h + 1.0 if h < 0.0 else h h = h - 1.0 if h > 1.0 else h else: # Interpolating over less than a half-circle, so use normal interpolation as before h = alpha_h * h1 + (1 - alpha_h) * h2 l = alpha_l * l1 + (1 - alpha_l) * l2 s = alpha_s * s1 + (1 - alpha_s) * s2 r,g,b = colorsys.hls_to_rgb(h, l, s) r = round(r * 255.0) g = round(g * 255.0) b = round(b * 255.0) t = rgb2int(int(r),int(g),int(b)) return int(t) def int2rgb(x): b = (x >> 16) & 0xff g = (x >> 8) & 0xff r = x & 0xff return r,g,b def int2rgbstr(x): return '#%02x%02x%02x' % int2rgb(x) def rgb2int(r,g,b): return r + g*256 + b*256*256 def add_alpha(i): """Add an opaque alpha channel to an integer RGB value""" r = i & 0xff g = (i >> 8) & 0xff b = (i >> 16) & 0xff a = 0xff # Opaque alpha return r,g,b,a