Attached are a couple of instrumented files that should tell us what is happening. Could you rename:
/usr/share/weewx/weeplot/genplot.py to /usr/share/weewx/weeplot/genplot_orig.py /usr/share/weewx/weeplot/utilities.py to /usr/share/weewx/weeplot/utilities_orig.py and then download and save the attached genplot.py and utilities.py to directory usr/share/weewx/weeplot Then restart WeeWX and let it complete at least one full report cycle. Take a copy of the log from WeeWX startup through until the first report cycle finishes and post the log here. Gary On Tuesday, 6 April 2021 at 14:47:59 UTC+10 [email protected] wrote: > Yes, /etc/weewx/skins/Seasons/font/OpenSans-Bold.ttf. Weewx version is > 4.40, Python is 2.7.17. This is on Ubuntu 18.04.5 LTS. > > --Richard > > On Monday, April 5, 2021 at 8:55:46 PM UTC-7 gjr80 wrote: > >> So when you say that '"font/OpenSans-Bold.ttf", which does exist', where >> exactly does it exist? /etc/weewx/skins/Seasons/font/OpenSans-Bold.ttf? >> OpenSans-Bold.ttf is more than adequate, my install is using it and >> rendering µg/m³ just fine. The symptoms sounds very much like PIL is giving >> you a default font every time. What version of WeeWx are you using and >> under which version of python is it running(will be in the log on WeeWX >> startup)? >> >> Gary >> >> On Tuesday, 6 April 2021 at 10:00:06 UTC+10 [email protected] wrote: >> >>> Dug through that. The font referenced for unit_label_font_path in >>> skin.conf for Seasons is "font/OpenSans-Bold.ttf", which does exist. >>> Thinking it might not have full support for the Unicode math symbols, I >>> changed that to "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", which >>> supposedly covers all 256 symbols. And I still have the weird unit >>> label...nothing there changed. I see no error messages WRT rendering in >>> the log as described in the User's Guide. >>> >>> What's quite odd is that the rendered text contains both the "micro" and >>> the "cubed" symbols...it just has the extra garbage as well. >>> >>> And I found where this unit value gets defined: in >>> /usr/share/weewx/unit.py: "microgram_per_meter_cubed": u"µg/m³". In the >>> HTML for the "current" line, this looks reasonable: "µg/m³". >>> But I have yet to make the image generator happy. >>> >>> --Richard >>> >>> >>> >>> On Monday, April 5, 2021 at 2:39:06 PM UTC-7 gjr80 wrote: >>> >>>> Hi, >>>> >>>> Nothing to do with drivers or extensions, it’s a font issue. Have a >>>> read of the section Funky symbols in plots >>>> <http://weewx.com/docs/usersguide.htm#funky_symbols> in the User’s >>>> Guide. >>>> >>>> Gary >>>> On Tuesday, 6 April 2021 at 07:31:17 UTC+10 [email protected] wrote: >>>> >>>>> This is a minor annoyance, but I am failing to figure out where this >>>>> is being done, or how to fix it. Units for PM2.5 are micrograms per >>>>> meter >>>>> cubed. I've got a "current" where the units look as expected: µg/m³. >>>>> >>>>> But in the generated history images, the units in the upper show as >>>>> shown in this image: [image: Screen Shot 2021-04-05 at 2.27.13 PM.png] >>>>> Station data is coming from the WX1000 extension and in this case, >>>>> some of it from the PurpleAir extension. Looking through both >>>>> extensions, >>>>> I am not seeing who/what is choosing the unit string for these values, >>>>> nor >>>>> where it's getting sucked into the image generator. Hints? I am a week >>>>> into using WeeWX, and am quite impressed, but finding where the bodies >>>>> are >>>>> buried for this sort of magic. >>>>> >>>>> --Richard >>>>> >>>>> -- You received this message because you are subscribed to the Google Groups "weewx-user" group. To unsubscribe from this group and stop receiving emails from it, send an email to [email protected]. To view this discussion on the web visit https://groups.google.com/d/msgid/weewx-user/d9e7b7d1-bbf1-470b-97fe-d3a6ffbfd651n%40googlegroups.com.
# # Copyright (c) 2009-2021 Tom Keffer <[email protected]> # # See the file LICENSE.txt for your full rights. # """Various utilities used by the plot package. """ from __future__ import absolute_import from __future__ import print_function from six.moves import zip try: from PIL import ImageFont, ImageColor except ImportError: import ImageFont, ImageColor import datetime import logging import time import math import six import weeplot log = logging.getLogger(__name__) def scale(data_min, data_max, prescale=(None, None, None), nsteps=10): """Calculates an appropriate min, max, and step size for scaling axes on a plot. The origin (zero) is guaranteed to be on an interval boundary. data_min: The minimum data value data_max: The maximum data value. Must be greater than or equal to data_min. prescale: A 3-way tuple. A non-None min or max value (positions 0 and 1, respectively) will be fixed to that value. A non-None interval (position 2) be at least as big as that value. Default = (None, None, None) nsteps: The nominal number of desired steps. Default = 10 Returns: a three-way tuple. First value is the lowest scale value, second the highest. The third value is the step (increment) between them. Examples: >>> print("(%.1f, %.1f, %.1f)" % scale(1.1, 12.3, (0, 14, 2))) (0.0, 14.0, 2.0) >>> print("(%.1f, %.1f, %.1f)" % scale(1.1, 12.3)) (0.0, 14.0, 2.0) >>> print("(%.1f, %.1f, %.1f)" % scale(-1.1, 12.3)) (-2.0, 14.0, 2.0) >>> print("(%.1f, %.1f, %.1f)" % scale(-12.1, -5.3)) (-13.0, -5.0, 1.0) >>> print("(%.2f, %.2f, %.2f)" % scale(10.0, 10.0)) (10.00, 10.10, 0.01) >>> print("(%.2f, %.4f, %.4f)" % scale(10.0, 10.001)) (10.00, 10.0010, 0.0001) >>> print("(%.2f, %.2f, %.2f)" % scale(10.0, 10.0+1e-8)) (10.00, 10.10, 0.01) >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.05, (None, None, .1), 10)) (0.00, 1.00, 0.10) >>> print("(%.2f, %.2f, %.2f)" % scale(16.8, 21.5, (None, None, 2), 10)) (16.00, 36.00, 2.00) >>> print("(%.2f, %.2f, %.2f)" % scale(16.8, 21.5, (None, None, 2), 4)) (16.00, 22.00, 2.00) >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.21, (None, None, .02))) (0.00, 0.22, 0.02) >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (None, 100, None))) (99.00, 100.00, 0.20) >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (100, None, None))) (100.00, 101.00, 0.20) >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (0, None, None))) (0.00, 120.00, 20.00) >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.2, (None, 100, None))) (0.00, 100.00, 20.00) """ # If all the values are hard-wired in, then there's nothing to do: if None not in prescale: return prescale # Unpack minscale, maxscale, min_interval = prescale # Make sure data_min and data_max are float values, in case a user passed # in integers: data_min = float(data_min) data_max = float(data_max) if data_max < data_min: raise weeplot.ViolatedPrecondition("scale() called with max value less than min value") # In case minscale and/or maxscale was specified, clip data_min and data_max to make sure they # stay within bounds if maxscale is not None: data_max = min(data_max, maxscale) if data_max < data_min: data_min = data_max if minscale is not None: data_min = max(data_min, minscale) if data_max < data_min: data_max = data_min # Check the special case where the min and max values are equal. if _rel_approx_equal(data_min, data_max): # They are equal. We need to move one or the other to create a range, while # being careful that the resultant min/max stay within the interval [minscale, maxscale] # Pick a step out value based on min_interval if the user has supplied one. Otherwise, # arbitrarily pick 0.1 if min_interval is not None: step_out = min_interval * nsteps else: step_out = 0.01 * round(abs(data_max), 2) if data_max else 0.1 if maxscale is not None: # maxscale if fixed. Move data_min. data_min = data_max - step_out elif minscale is not None: # minscale if fixed. Move data_max. data_max = data_min + step_out else: # Both can float. Check special case where data_min and data_max are zero if data_min == 0.0: data_max = 1.0 else: # Just arbitrarily move one. Say, data_max. data_max = data_min + step_out if minscale is not None and maxscale is not None: if maxscale < minscale: raise weeplot.ViolatedPrecondition("scale() called with prescale max less than min") frange = maxscale - minscale elif minscale is not None: frange = data_max - minscale elif maxscale is not None: frange = maxscale - data_min else: frange = data_max - data_min steps = frange / float(nsteps) mag = math.floor(math.log10(steps)) magPow = math.pow(10.0, mag) magMsd = math.floor(steps / magPow + 0.5) if magMsd > 5.0: magMsd = 10.0 elif magMsd > 2.0: magMsd = 5.0 else: # magMsd > 1.0 magMsd = 2 # This will be the nominal interval size interval = magMsd * magPow # Test it against the desired minimum, if any if min_interval is None or interval >= min_interval: # Either no min interval was specified, or its safely # less than the chosen interval. if minscale is None: minscale = interval * math.floor(data_min / interval) if maxscale is None: maxscale = interval * math.ceil(data_max / interval) else: # The request for a minimum interval has kicked in. # Sometimes this can make for a plot with just one or # two intervals in it. Adjust the min and max values # to get a nice plot interval = float(min_interval) if minscale is None: if maxscale is None: # Both can float. Pick values so the range is near the bottom # of the scale: minscale = interval * math.floor(data_min / interval) maxscale = minscale + interval * nsteps else: # Only minscale can float minscale = maxscale - interval * nsteps else: if maxscale is None: # Only maxscale can float maxscale = minscale + interval * nsteps else: # Both are fixed --- nothing to be done pass return minscale, maxscale, interval def scaletime(tmin_ts, tmax_ts) : """Picks a time scaling suitable for a time plot. tmin_ts, tmax_ts: The time stamps in epoch time around which the times will be picked. Returns a scaling 3-tuple. First element is the start time, second the stop time, third the increment. All are in seconds (epoch time in the case of the first two). Example 1: 24 hours on an hour boundary >>> from weeutil.weeutil import timestamp_to_string as to_string >>> time_ts = time.mktime(time.strptime("2013-05-17 08:00", "%Y-%m-%d %H:%M")) >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) >>> print(to_string(xmin), to_string(xmax), xinc) 2013-05-16 09:00:00 PDT (1368720000) 2013-05-17 09:00:00 PDT (1368806400) 10800 Example 2: 24 hours on a 3-hour boundary >>> time_ts = time.mktime(time.strptime("2013-05-17 09:00", "%Y-%m-%d %H:%M")) >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) >>> print(to_string(xmin), to_string(xmax), xinc) 2013-05-16 09:00:00 PDT (1368720000) 2013-05-17 09:00:00 PDT (1368806400) 10800 Example 3: 24 hours on a non-hour boundary >>> time_ts = time.mktime(time.strptime("2013-05-17 09:01", "%Y-%m-%d %H:%M")) >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts) >>> print(to_string(xmin), to_string(xmax), xinc) 2013-05-16 12:00:00 PDT (1368730800) 2013-05-17 12:00:00 PDT (1368817200) 10800 Example 4: 27 hours >>> time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M")) >>> xmin, xmax, xinc = scaletime(time_ts - 27*3600, time_ts) >>> print(to_string(xmin), to_string(xmax), xinc) 2013-05-16 06:00:00 PDT (1368709200) 2013-05-17 09:00:00 PDT (1368806400) 10800 Example 5: 3 hours on a 15 minute boundary >>> time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M")) >>> xmin, xmax, xinc = scaletime(time_ts - 3*3600, time_ts) >>> print(to_string(xmin), to_string(xmax), xinc) 2013-05-17 05:00:00 PDT (1368792000) 2013-05-17 08:00:00 PDT (1368802800) 900 Example 6: 3 hours on a non-15 minute boundary >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) >>> xmin, xmax, xinc = scaletime(time_ts - 3*3600, time_ts) >>> print(to_string(xmin), to_string(xmax), xinc) 2013-05-17 05:00:00 PDT (1368792000) 2013-05-17 08:00:00 PDT (1368802800) 900 Example 7: 12 hours >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) >>> xmin, xmax, xinc = scaletime(time_ts - 12*3600, time_ts) >>> print(to_string(xmin), to_string(xmax), xinc) 2013-05-16 20:00:00 PDT (1368759600) 2013-05-17 08:00:00 PDT (1368802800) 3600 Example 8: 15 hours >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M")) >>> xmin, xmax, xinc = scaletime(time_ts - 15*3600, time_ts) >>> print(to_string(xmin), to_string(xmax), xinc) 2013-05-16 17:00:00 PDT (1368748800) 2013-05-17 08:00:00 PDT (1368802800) 7200 """ if tmax_ts <= tmin_ts : raise weeplot.ViolatedPrecondition("scaletime called with tmax <= tmin") tdelta = tmax_ts - tmin_ts tmin_dt = datetime.datetime.fromtimestamp(tmin_ts) tmax_dt = datetime.datetime.fromtimestamp(tmax_ts) if tdelta <= 16 * 3600: if tdelta <= 3*3600: # For time intervals less than 3 hours, use an increment of 15 minutes interval = 900 elif tdelta <= 12 * 3600: # For intervals from 3 hours up through 12 hours, use one hour interval = 3600 else: # For intervals from 12 through 16 hours, use two hours. interval = 7200 # Get to the one hour boundary below tmax: stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0) # if tmax happens to be on a one hour boundary we're done. Otherwise, round # up to the next one hour boundary: if tmax_dt > stop_dt: stop_dt += datetime.timedelta(hours=1) n_hours = int((tdelta + 3599) / 3600) start_dt = stop_dt - datetime.timedelta(hours=n_hours) elif tdelta <= 27 * 3600: # A day plot is wanted. A time increment of 3 hours is appropriate interval = 3 * 3600 # h is the hour of tmax_dt h = tmax_dt.timetuple()[3] # Subtract off enough to get to the lower 3-hour boundary from tmax: stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0) - datetime.timedelta(hours = h % 3) # If tmax happens to lie on a 3 hour boundary we don't need to do anything. If not, we need # to round up to the next 3 hour boundary: if tmax_dt > stop_dt: stop_dt += datetime.timedelta(hours=3) # The stop time is one day earlier start_dt = stop_dt - datetime.timedelta(days=1) if tdelta == 27 * 3600 : # A "slightly more than a day plot" is wanted. Start 3 hours earlier: start_dt -= datetime.timedelta(hours=3) elif 27 * 3600 < tdelta <= 31 * 24 * 3600 : # The time scale is between a day and a month. A time increment of one day is appropriate start_dt = tmin_dt.replace(hour=0, minute=0, second=0, microsecond=0) stop_dt = tmax_dt.replace(hour=0, minute=0, second=0, microsecond=0) tmax_tt = tmax_dt.timetuple() if tmax_tt[3]!=0 or tmax_tt[4]!=0 : stop_dt += datetime.timedelta(days=1) interval = 24 * 3600 elif tdelta < 2 * 365.25 * 24 * 3600 : # The time scale is between a month and 2 years. A time increment of a month is appropriate start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) (year , mon, day) = tmax_dt.timetuple()[0:3] if day != 1 : mon += 1 if mon==13 : mon = 1 year += 1 stop_dt = datetime.datetime(year, mon, 1) # Average month length: interval = 365.25/12 * 24 * 3600 else : # The time scale is between a month and 2 years. A time increment of a year is appropriate start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) (year , mon, day) = tmax_dt.timetuple()[0:3] if day != 1 or mon !=1 : day = 1 mon = 1 year += 1 stop_dt = datetime.datetime(year, mon, 1) # Average year length interval = 365.25 * 24 * 3600 # Convert to epoch time stamps start_ts = int(time.mktime(start_dt.timetuple())) stop_ts = int(time.mktime(stop_dt.timetuple())) return (start_ts, stop_ts, interval) class ScaledDraw(object): """Like an ImageDraw object, but lines are scaled. """ def __init__(self, draw, imagebox, scaledbox): """Initialize a ScaledDraw object. Example: scaledraw = ScaledDraw(draw, ((10, 10), (118, 246)), ((0.0, 0.0), (10.0, 1.0))) would create a scaled drawing where the upper-left image coordinate (10, 10) would correspond to the scaled coordinate( 0.0, 1.0). The lower-right image coordinate would correspond to the scaled coordinate (10.0, 0.0). draw: an instance of ImageDraw imagebox: a 2-tuple of the box coordinates on the image ((ulx, uly), (lrx, lry)) scaledbox: a 2-tuple of the box coordinates of the scaled plot ((llx, lly), (urx, ury)) """ uli = imagebox[0] lri = imagebox[1] lls = scaledbox[0] urs = scaledbox[1] if urs[1] == lls[1]: pass self.xscale = float(lri[0] - uli[0]) / float(urs[0] - lls[0]) self.yscale = -float(lri[1] - uli[1]) / float(urs[1] - lls[1]) self.xoffset = int(lri[0] - urs[0] * self.xscale + 0.5) self.yoffset = int(uli[1] - urs[1] * self.yscale + 0.5) self.draw = draw def line(self, x, y, line_type='solid', marker_type=None, marker_size=8, maxdx=None, **options) : """Draw a scaled line on the instance's ImageDraw object. x: sequence of x coordinates y: sequence of y coordinates, some of which are possibly null (value of None) line_type: 'solid' for line that connect the coordinates None for no line marker_type: None or 'none' for no marker. 'cross' for a cross 'circle' for a circle 'box' for a box 'x' for an X maxdx: defines what constitutes a gap in samples. if two data points are more than maxdx apart they are treated as separate segments. For a scatter plot, set line_type to None and marker_type to something other than None. """ # Break the line up around any nulls or gaps between samples for xy_seq in xy_seq_line(x, y, maxdx): # Create a list with the scaled coordinates... xy_seq_scaled = [(self.xtranslate(xc), self.ytranslate(yc)) for (xc,yc) in xy_seq] if line_type == 'solid': # Now pick the appropriate drawing function, depending on the length of the line: if len(xy_seq) == 1 : self.draw.point(xy_seq_scaled, fill=options['fill']) else : self.draw.line(xy_seq_scaled, **options) if marker_type and marker_type.lower().strip() not in ['none', '']: self.marker(xy_seq_scaled, marker_type, marker_size=marker_size, **options) def marker(self, xy_seq, marker_type, marker_size=10, **options): half_size = marker_size/2 marker=marker_type.lower() for x, y in xy_seq: if marker == 'cross': self.draw.line([(x-half_size, y), (x+half_size, y)], **options) self.draw.line([(x, y-half_size), (x, y+half_size)], **options) elif marker == 'x': self.draw.line([(x-half_size, y-half_size), (x+half_size, y+half_size)], **options) self.draw.line([(x-half_size, y+half_size), (x+half_size, y-half_size)], **options) elif marker == 'circle': self.draw.ellipse([(x-half_size, y-half_size), (x+half_size, y+half_size)], outline=options['fill']) elif marker == 'box': self.draw.line([(x-half_size, y-half_size), (x+half_size, y-half_size), (x+half_size, y+half_size), (x-half_size, y+half_size), (x-half_size, y-half_size)], **options) def rectangle(self, box, **options) : """Draw a scaled rectangle. box: A pair of 2-way tuples, containing coordinates of opposing corners of the box. options: passed on to draw.rectangle. Usually contains 'fill' (the color) """ box_scaled = [(coord[0]*self.xscale + self.xoffset + 0.5, coord[1]*self.yscale + self.yoffset + 0.5) for coord in box] self.draw.rectangle(box_scaled, **options) def vector(self, x, vec, vector_rotate, **options): if vec is None: return xstart_scaled = self.xtranslate(x) ystart_scaled = self.ytranslate(0) vecinc_scaled = vec * self.yscale if vector_rotate: vecinc_scaled *= complex(math.cos(math.radians(vector_rotate)), math.sin(math.radians(vector_rotate))) # Subtract off the x increment because the x-axis # *increases* to the right, unlike y, which increases # downwards xend_scaled = xstart_scaled - vecinc_scaled.real yend_scaled = ystart_scaled + vecinc_scaled.imag self.draw.line(((xstart_scaled, ystart_scaled), (xend_scaled, yend_scaled)), **options) def xtranslate(self, x): return int(x * self.xscale + self.xoffset + 0.5) def ytranslate(self, y): return int(y * self.yscale + self.yoffset + 0.5) def xy_seq_line(x, y, maxdx=None): """Generator function that breaks a line up into individual segments around any nulls held in y or any gaps in x greater than maxdx. x: iterable sequence of x coordinates. All values must be non-null y: iterable sequence of y coordinates, possibly with some embedded nulls (that is, their value==None) yields: Lists of (x,y) coordinates Example 1 >>> x=[ 1, 2, 3] >>> y=[10, 20, 30] >>> for xy_seq in xy_seq_line(x,y): ... print(xy_seq) [(1, 10), (2, 20), (3, 30)] Example 2 >>> x=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] >>> y=[0, 10, None, 30, None, None, 60, 70, 80, None] >>> for xy_seq in xy_seq_line(x,y): ... print(xy_seq) [(0, 0), (1, 10)] [(3, 30)] [(6, 60), (7, 70), (8, 80)] Example 3 >>> x=[ 0 ] >>> y=[None] >>> for xy_seq in xy_seq_line(x,y): ... print(xy_seq) Example 4 >>> x=[ 0, 1, 2] >>> y=[None, None, None] >>> for xy_seq in xy_seq_line(x,y): ... print(xy_seq) Example 5 (using gap) >>> x=[0, 1, 2, 3, 5.1, 6, 7, 8, 9] >>> y=[0, 10, 20, 30, 50, 60, 70, 80, 90] >>> for xy_seq in xy_seq_line(x,y,2): ... print(xy_seq) [(0, 0), (1, 10), (2, 20), (3, 30)] [(5.1, 50), (6, 60), (7, 70), (8, 80), (9, 90)] """ line = [] last_x = None for xy in zip(x, y): dx = xy[0] - last_x if last_x is not None else 0 last_x = xy[0] # If the y coordinate is None or dx > maxdx, that marks a break if xy[1] is None or (maxdx is not None and dx > maxdx): # If the length of the line is non-zero, yield it if len(line): yield line line = [] if xy[1] is None else [xy] else: line.append(xy) if len(line): yield line def pickLabelFormat(increment): """Pick an appropriate label format for the given increment. Examples: >>> print(pickLabelFormat(1)) %.0f >>> print(pickLabelFormat(20)) %.0f >>> print(pickLabelFormat(.2)) %.1f >>> print(pickLabelFormat(.01)) %.2f """ i_log = math.log10(increment) if i_log < 0 : i_log = abs(i_log) decimal_places = int(i_log) if i_log != decimal_places : decimal_places += 1 else : decimal_places = 0 return "%%.%df" % decimal_places def get_font_handle(fontpath, *args): """Get a handle for a font path, caching the results""" # For Python 2, we want to make sure fontpath is a string, not unicode fontpath_str = six.ensure_str(fontpath) if fontpath is not None else None log.info("fontpath_str='%s'" % (fontpath_str,)) # Look for the font in the cache font_key = (fontpath_str, args) if font_key in get_font_handle.fontCache: log.info("got font handle from cache") return get_font_handle.fontCache[font_key] font = None if fontpath_str is not None : try : if fontpath_str.endswith('.ttf'): log.info("getting .ttf font handle") font = ImageFont.truetype(fontpath_str, *args) else : log.info("getting non-ttf font handle") font = ImageFont.load_path(fontpath_str) except IOError : log.info("couldn't get find font") pass if font is None : log.info("getting default font handle") font = ImageFont.load_default() if font is not None : log.info("cached font handle") get_font_handle.fontCache[font_key] = font return font get_font_handle.fontCache={} def _rel_approx_equal(x, y, rel=1e-7): """Relative test for equality. Example >>> rel_approx_equal(1.23456, 1.23457) False >>> rel_approx_equal(1.2345678, 1.2345679) True >>> rel_approx_equal(0.0, 0.0) True >>> rel_approx_equal(0.0, 0.1) False >>> rel_approx_equal(0.0, 1e-9) False >>> rel_approx_equal(1.0, 1.0+1e-9) True >>> rel_approx_equal(1e8, 1e8+1e-3) True """ return abs(x-y) <= rel*max(abs(x), abs(y)) def tobgr(x): """Convert a color to little-endian integer. The PIL wants either a little-endian integer (0xBBGGRR) or a string (#RRGGBB). weewx expects little-endian integer. Accept any standard color format that is known by ImageColor for example #RGB, #RRGGBB, hslHSL as well as standard color names from X11 and CSS3. See ImageColor for complete set of colors. """ if isinstance(x, six.string_types): if x.startswith('0x'): return int(x, 0) try: (r,g,b) = ImageColor.getrgb(x) return r + g*256 + b*256*256 except ValueError: try: return int(x) except ValueError: raise ValueError("Unknown color specifier: '%s'. " "Colors must be specified as 0xBBGGRR, #RRGGBB, or standard color names." % x) return x if __name__ == "__main__": import doctest if not doctest.testmod().failed: print("PASSED")
# # Copyright (c) 2009-2019 Tom Keffer <[email protected]> # # See the file LICENSE.txt for your full rights. # """Routines for generating image plots.""" from __future__ import absolute_import import colorsys import locale import logging import os import time try: from PIL import Image, ImageDraw except ImportError: import Image, ImageDraw from six.moves import zip import six import weeplot.utilities from weeplot.utilities import tobgr import weeutil.weeutil import weewx log = logging.getLogger(__name__) class GeneralPlot(object): """Holds various parameters necessary for a plot. It should be specialized by the type of plot. """ def __init__(self, config_dict): """Initialize an instance of GeneralPlot. config_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(config_dict.get('anti_alias', 1)) self.image_width = int(config_dict.get('image_width', 300)) * self.anti_alias self.image_height = int(config_dict.get('image_height', 180)) * self.anti_alias self.image_background_color = tobgr(config_dict.get('image_background_color', '0xf5f5f5')) self.chart_background_color = tobgr(config_dict.get('chart_background_color', '0xd8d8d8')) self.chart_gridline_color = tobgr(config_dict.get('chart_gridline_color', '0xa0a0a0')) color_list = config_dict.get('chart_line_colors', ['0xff0000', '0x00ff00', '0x0000ff']) fill_color_list = config_dict.get('chart_fill_colors', color_list) width_list = config_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 = config_dict.get('top_label_font_path') self.top_label_font_size = int(config_dict.get('top_label_font_size', 10)) * self.anti_alias self.unit_label = None self.unit_label_font_path = config_dict.get('unit_label_font_path') log.info("self.unit_label_font_path initialised to '%s'" % self.unit_label_font_path) self.unit_label_font_color = tobgr(config_dict.get('unit_label_font_color', '0x000000')) self.unit_label_font_size = int(config_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 = config_dict.get('bottom_label_font_path') self.bottom_label_font_color= tobgr(config_dict.get('bottom_label_font_color', '0x000000')) self.bottom_label_font_size = int(config_dict.get('bottom_label_font_size', 10)) * self.anti_alias self.bottom_label_offset = int(config_dict.get('bottom_label_offset', 3)) self.axis_label_font_path = config_dict.get('axis_label_font_path') self.axis_label_font_color = tobgr(config_dict.get('axis_label_font_color', '0x000000')) self.axis_label_font_size = int(config_dict.get('axis_label_font_size', 10)) * self.anti_alias self.x_label_format = config_dict.get('x_label_format') self.y_label_format = config_dict.get('y_label_format') self.x_nticks = int(config_dict.get('x_nticks', 10)) self.y_nticks = int(config_dict.get('y_nticks', 10)) self.x_label_spacing = int(config_dict.get('x_label_spacing', 2)) self.y_label_spacing = int(config_dict.get('y_label_spacing', 2)) # Calculate sensible margins for the given image and font sizes. self.y_label_side = config_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(config_dict.get('rose_width', 21)) self.rose_height = int(config_dict.get('rose_height', 21)) self.rose_diameter = int(config_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 = config_dict.get('rose_label', u'N') self.rose_label_font_path = config_dict.get('rose_label_font_path', self.bottom_label_font_path) self.rose_label_font_size = int(config_dict.get('rose_label_font_size', 10)) self.rose_label_font_color = tobgr(config_dict.get('rose_label_font_color', '0x000000')) self.rose_line_width = int(config_dict.get('rose_line_width', 1)) self.rose_color = config_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 = weeutil.weeutil.tobool(config_dict.get('show_daynight', False)) self.daynight_day_color = tobgr(config_dict.get('daynight_day_color', '0xffffff')) self.daynight_night_color = tobgr(config_dict.get('daynight_night_color', '0xf0f0f0')) self.daynight_edge_color = tobgr(config_dict.get('daynight_edge_color', '0xefefef')) self.daynight_gradient = int(config_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 = config_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) log.info("skin_dir='%s' normalised self.unit_label_font_path='%s'" % (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 = self._getImageDraw(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.ANTIALIAS) return image # noinspection PyMethodMayBeStatic def _getImageDraw(self, image): """Returns an instance of ImageDraw with the proper dimensions and background color""" draw = UniDraw(image) return draw 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) axis_label_size = sdraw.draw.textsize(xlabel, font=axis_label_font) xpos = sdraw.xtranslate(x) sdraw.draw.text((xpos - axis_label_size[0]/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) axis_label_size = sdraw.draw.textsize(ylabel, font=axis_label_font) ypos = sdraw.ytranslate(y) if self.y_label_side == 'left' or self.y_label_side == 'both': sdraw.draw.text( (self.lmargin - axis_label_size[0] - 2, ypos - axis_label_size[1]/2), ylabel, fill=self.axis_label_font_color, font=axis_label_font) if self.y_label_side == 'right' or self.y_label_side == 'both': sdraw.draw.text( (self.image_width - self.rmargin + 4, ypos - axis_label_size[1]/2), ylabel, fill=self.axis_label_font_color, font=axis_label_font) 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.gap_fraction is not None: maxdx = this_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) bottom_label_size = draw.textsize(self.bottom_label, font=bottom_label_font) draw.text(((self.image_width - bottom_label_size[0])/2, self.image_height - bottom_label_size[1] - self.bottom_label_offset), self.bottom_label, fill=self.bottom_label_font_color, font=bottom_label_font) 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 log.info("getting unit_label_font handle") unit_label_font = weeplot.utilities.get_font_handle(self.unit_label_font_path, self.unit_label_font_size) log.info("got unit_label_font handle") 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 = ' '.join([line.label for line in self.line_list]) top_label_size = draw.textsize(top_label, font=top_label_font) x = (self.image_width - top_label_size[0])/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: label_size = draw.textsize(this_line.label + ' ', font= top_label_font) x += label_size[0] 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) rose_label_size = 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_size[0]/2 - 1, rose_center_y - rose_label_size[1]/2 - 1), self.rose_label, fill = add_alpha(self.rose_label_font_color), font = rose_label_font) # 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 = weeutil.weeutil.min_with_none(line.y) yline_max = weeutil.weeutil.max_with_none(line.y) ymin = weeutil.weeutil.min_with_none([ymin, yline_min]) ymax = weeutil.weeutil.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 = weeutil.weeutil.min_with_none(line.x) xline_max = weeutil.weeutil.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 = weeutil.weeutil.min_with_none([xmin, xline_min]) xmax = weeutil.weeutil.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.""" 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): if self.x_label_format is None: return '' 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, 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.gap_fraction = gap_fraction class UniDraw(ImageDraw.ImageDraw): """Supports non-Unicode fonts Not all fonts support Unicode characters. These will raise a UnicodeEncodeError exception. This class subclasses the regular ImageDraw.Draw class, adding overridden functions to catch these exceptions. It then tries drawing the string again, this time as a UTF8 string """ def text(self, position, string, **options): try: return ImageDraw.ImageDraw.text(self, position, string, **options) except UnicodeEncodeError: return ImageDraw.ImageDraw.text(self, position, string.encode('utf-8'), **options) def textsize(self, string, **options): try: return ImageDraw.ImageDraw.textsize(self, string, **options) except UnicodeEncodeError: return ImageDraw.ImageDraw.textsize(self, string.encode('utf-8'), **options) 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
