PaletteOptimiser

optimiser.py

57:1af9f6724ea2
2015-10-08 Paul Boddie Made the experimental square function application slightly cleaner.
     1 #!/usr/bin/env python     2      3 """     4 Convert and optimise images for display in an Acorn Electron MODE 1 variant     5 with four colours per line but eight colours available for selection for each     6 line.     7      8 Copyright (C) 2015 Paul Boddie <paul@boddie.org.uk>     9     10 This program is free software; you can redistribute it and/or modify it under    11 the terms of the GNU General Public License as published by the Free Software    12 Foundation; either version 3 of the License, or (at your option) any later    13 version.    14     15 This program is distributed in the hope that it will be useful, but WITHOUT ANY    16 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A    17 PARTICULAR PURPOSE.  See the GNU General Public License for more details.    18     19 You should have received a copy of the GNU General Public License along    20 with this program.  If not, see <http://www.gnu.org/licenses/>.    21 """    22     23 from random import random    24 from os.path import split, splitext    25 import EXIF    26 import PIL.Image    27 import math    28 import sys    29     30 corners = [    31     (0, 0, 0), (255, 0, 0), (0, 255, 0), (255, 255, 0),    32     (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255)    33     ]    34     35 # Basic colour operations.    36     37 def within(v, lower, upper):    38     return min(max(v, lower), upper)    39     40 def clip(v):    41     return within(v, 0, 255)    42     43 def distance(rgb1, rgb2):    44     r1, g1, b1 = rgb1    45     r2, g2, b2 = rgb2    46     return math.sqrt(pow(r1 - r2, 2) + pow(g1 - g2, 2) + pow(b1 - b2, 2))    47     48 def nearest(rgb, values):    49     l = [(distance(rgb, value), value) for value in values]    50     l.sort()    51     return l[0][1]    52     53 def restore(srgb):    54     return tuple(map(lambda x: int(x * 255.0), srgb))    55     56 def scale(rgb):    57     return tuple(map(lambda x: x / 255.0, rgb))    58     59 def square(srgb):    60     return tuple(map(lambda x: pow(x, 2), srgb))    61     62 def invert(srgb):    63     return tuple(map(lambda x: 1.0 - x, srgb))    64     65 # Colour distribution functions.    66     67 cache = {}    68     69 def combination(rgb):    70     71     "Return the colour distribution for 'rgb'."    72     73     if not cache.has_key(rgb):    74     75         # Get the colour with components scaled from 0 to 1, plus the inverted    76         # component values.    77     78         rgb = extra(scale(rgb))    79         rgbi = invert(rgb)    80         pairs = zip(rgbi, rgb)    81     82         # For each corner of the colour cube (primary and secondary colours plus    83         # black and white), calculate the corner value's contribution to the    84         # input colour.    85     86         d = []    87         for corner in corners:    88             rs, gs, bs = scale(corner)    89     90             # Obtain inverted channel values where corner channels are low;    91             # obtain original channel values where corner channels are high.    92     93             d.append((pairs[0][int(rs)] * pairs[1][int(gs)] * pairs[2][int(bs)], corner))    94     95         # Balance the corner contributions.    96     97         cache[rgb] = balance(d)    98     99     return cache[rgb]   100    101 def complements(rgb):   102    103     "Return 'rgb' and its complement."   104    105     r, g, b = rgb   106     return rgb, restore(invert(scale(rgb)))   107    108 def balance(d):   109    110     """   111     Balance distribution 'd', cancelling opposing values and their complements   112     and replacing their common contributions with black and white contributions.   113     """   114    115     d = dict([(value, f) for f, value in d])   116     for primary, secondary in map(complements, [(255, 0, 0), (0, 255, 0), (0, 0, 255)]):   117         common = min(d[primary], d[secondary])   118         d[primary] -= common   119         d[secondary] -= common   120         d[(0, 0, 0)] += common   121         d[(255, 255, 255)] += common   122     return [(f, value) for value, f in d.items()]   123    124 def compensate(d, chosen):   125    126     """   127     Compensate distribution 'd' for the given 'chosen' colours, reducing chosen   128     colour contributions where their complements are not part of the chosen set.   129     """   130    131     dd = dict([(value, f) for f, value in d])   132     for f, value in d:   133         if value in chosen:   134             _value, complement = complements(value)   135             if complement not in chosen:   136                 f = max(0, f - dd[complement])   137                 dd[value] = f   138     return [(f, value) for value, f in dd.items() if value in chosen]   139    140 def combine(d):   141    142     "Combine distribution 'd' to get a colour value."   143    144     out = [0, 0, 0]   145     for v, rgb in d:   146         out[0] += v * rgb[0]   147         out[1] += v * rgb[1]   148         out[2] += v * rgb[2]   149     return out   150    151 def pattern(rgb, chosen=None):   152    153     """   154     Obtain a sorted colour distribution for 'rgb', optionally limited to any   155     specified 'chosen' colours.   156     """   157    158     l = combination(rgb)   159     if chosen:   160         l = compensate(l, chosen)   161     l.sort(reverse=True)   162     return l   163    164 def get_value(rgb, chosen=None):   165    166     """   167     Get an output colour for 'rgb', optionally limited to any specified 'chosen'   168     colours.   169     """   170    171     l = pattern(rgb, chosen)   172     limit = sum([f for f, c in l])   173     choose = random() * limit   174     threshold = 0   175     for f, c in l:   176         threshold += f   177         if choose < threshold:   178             return c   179     return c   180    181 # Colour processing operations.   182    183 def sign(x):   184     return x >= 0 and 1 or -1   185    186 def saturate_rgb(rgb, exp):   187     return tuple([saturate_value(x, exp) for x in rgb])   188    189 def saturate_value(x, exp):   190     return int(127.5 + sign(x - 127.5) * 127.5 * pow(abs(x - 127.5) / 127.5, exp))   191    192 def amplify_rgb(rgb, exp):   193     return tuple([amplify_value(x, exp) for x in rgb])   194    195 def amplify_value(x, exp):   196     return int(pow(x / 255.0, exp) * 255.0)   197    198 # Image operations.   199    200 def get_colours(im, y):   201    202     "Get a colour distribution from image 'im' for the row 'y'."   203    204     width, height = im.size   205     c = {}   206     for x in range(0, width):   207         rgb = im.getpixel((x, y))   208    209         # Sum the colour probabilities.   210    211         for f, value in combination(rgb):   212             if not c.has_key(value):   213                 c[value] = f   214             else:   215                 c[value] += f   216    217     c = [(n/width, value) for value, n in c.items()]   218     c.sort(reverse=True)   219     return c   220    221 def test():   222    223     "Generate slices of the colour cube."   224    225     size = 512   226     for r in (0, 63, 127, 191, 255):   227         im = PIL.Image.new("RGB", (size, size))   228         for g in range(0, size):   229             for b in range(0, size):   230                 value = get_value((r, (g * 256) / size, (b * 256 / size)))   231                 im.putpixel((g, b), value)   232         im.save("rgb%d.png" % r)   233    234 def test_flat(rgb):   235    236     "Generate a flat image for the colour 'rgb'."   237    238     size = 64   239     im = PIL.Image.new("RGB", (size, size))   240     for y in range(0, size):   241         for x in range(0, size):   242             im.putpixel((x, y), get_value(rgb))   243     im.save("rgb%02d%02d%02d.png" % rgb)   244    245 def rotate_and_scale(exif, im, width, height, rotate):   246    247     """   248     Using the given 'exif' information, rotate and scale image 'im' given the   249     indicated 'width' and 'height' constraints and any explicit 'rotate'   250     indication. The returned image will be within the given 'width' and   251     'height', filling either or both, and preserve its original aspect ratio.   252     """   253    254     if rotate or exif and exif["Image Orientation"].values == [6L]:   255         im = im.rotate(270)   256    257     w, h = im.size   258     if w > h:   259         height = (width * h) / w   260     else:   261         width = (height * w) / h   262    263     return im.resize((width, height))   264    265 def count_colours(im, colours):   266    267     """   268     Count colours on each row of image 'im', returning a tuple indicating the   269     first row with more than the given number of 'colours' together with the   270     found colours; otherwise returning None.   271     """   272    273     width, height = im.size   274     for y in range(0, height):   275         l = set()   276         for x in range(0, width):   277             l.add(im.getpixel((x, y)))   278         if len(l) > colours:   279             return (y, l)   280     return None   281    282 # Main program.   283    284 if __name__ == "__main__":   285    286     # Test options.   287    288     if "--test" in sys.argv:   289         test()   290         sys.exit(0)   291     elif "--test-flat" in sys.argv:   292         test_flat((120, 40, 60))   293         sys.exit(0)   294     elif "--help" in sys.argv:   295         print >>sys.stderr, """\   296 Usage: %s <input filename> <output filename> [ <options> ]   297    298 Options are...   299    300 -s - Saturate the input image (can be repeated)   301 -d - Desaturate the input image (can be repeated)   302 -D - Darken the input image (can be repeated)   303 -B - Brighten the input image (can be repeated)   304 -2 - Square/diminish the bright corner colour contributions (experimental)   305    306 -r - Rotate the input image clockwise   307 -p - Generate a separate preview image   308 -h - Make the preview image with half horizontal resolution (MODE 2)   309 -v - Verify the output image (loaded if -n is given)   310 -n - Generate no output image   311 """ % split(sys.argv[0])[1]   312         sys.exit(1)   313    314     width = 320   315     height = 256   316    317     input_filename, output_filename = sys.argv[1:3]   318     basename, ext = splitext(output_filename)   319     preview_filename = "".join([basename + "_preview", ext])   320    321     options = sys.argv[3:]   322    323     # Preprocessing options that can be repeated for extra effect.   324    325     saturate = options.count("-s")   326     desaturate = options.count("-d")   327     darken = options.count("-D")   328     brighten = options.count("-B")   329    330     # Experimental colour distribution modification.   331    332     use_square = "-2" in options   333     if use_square:   334         extra = square   335     else:   336         extra = (lambda x: x)   337    338     # General output options.   339    340     rotate = "-r" in options   341     preview = "-p" in options   342     half_resolution_preview = "-h" in options   343     verify = "-v" in options   344     no_normal_output = "-n" in options   345     make_image = not no_normal_output   346    347     # Load the input image if requested.   348    349     if make_image or preview:   350         exif = EXIF.process_file(open(input_filename))   351         im = PIL.Image.open(input_filename).convert("RGB")   352         im = rotate_and_scale(exif, im, width, height, rotate)   353    354         width, height = im.size   355    356         if saturate or desaturate or darken or brighten:   357             for y in range(0, height):   358                 for x in range(0, width):   359                     rgb = im.getpixel((x, y))   360                     if saturate or desaturate:   361                         rgb = saturate_rgb(rgb, saturate and math.pow(0.5, saturate) or math.pow(2, desaturate))   362                     if darken or brighten:   363                         rgb = amplify_rgb(rgb, brighten and math.pow(0.5, brighten) or math.pow(2, darken))   364                     im.putpixel((x, y), rgb)   365    366     # Generate a preview if requested.   367    368     if preview:   369         imp = im.copy()   370         step = half_resolution_preview and 2 or 1   371         for y in range(0, height):   372             for x in range(0, width, step):   373                 rgb = imp.getpixel((x, y))   374                 value = get_value(rgb)   375                 imp.putpixel((x, y), value)   376                 if half_resolution_preview:   377                     imp.putpixel((x+1, y), value)   378    379         imp.save(preview_filename)   380    381     # Generate an output image if requested.   382    383     if make_image:   384         for y in range(0, height):   385             c = get_colours(im, y)   386             most = [value for n, value in c[:4]]   387             least = [value for n, value in c[4:]]   388    389             for x in range(0, width):   390                 rgb = im.getpixel((x, y))   391                 value = get_value(rgb, most)   392                 im.putpixel((x, y), value)   393    394                 if y < height - 1:   395                     rgbn = im.getpixel((x, y+1))   396                     rgbn = tuple(map(lambda i: clip(i[0] + i[1] - i[2]), zip(rgbn, rgb, value)))   397                     im.putpixel((x, y+1), rgbn)   398    399         im.save(output_filename)   400    401     # Verify the output image (which may be loaded) if requested.   402    403     if verify:   404         if no_normal_output:   405             im = PIL.Image.open(output_filename).convert("RGB")   406    407         result = count_colours(im, 4)   408         if result is not None:   409             y, colours = result   410             print "Image %s: row %d has the following colours: %s" % (output_filename, y, "; ".join([repr(c) for c in colours]))   411    412 # vim: tabstop=4 expandtab shiftwidth=4