PaletteOptimiser

optimiser.py

75:429cc2ef9b9f
2015-10-10 Paul Boddie Experiment with a simple alternative class to Image for pixel access. simpleimage
     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 on 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, randrange    24 from os.path import split, splitext    25 import EXIF    26 import PIL.Image    27 import itertools    28 import math    29 import sys    30     31 corners = [    32     (0, 0, 0), (255, 0, 0), (0, 255, 0), (255, 255, 0),    33     (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255)    34     ]    35     36 # Basic colour operations.    37     38 def within(v, lower, upper):    39     return min(max(v, lower), upper)    40     41 def clip(v):    42     return int(within(v, 0, 255))    43     44 def distance(rgb1, rgb2):    45     r1, g1, b1 = rgb1    46     r2, g2, b2 = rgb2    47     return math.sqrt(pow(r1 - r2, 2) + pow(g1 - g2, 2) + pow(b1 - b2, 2))    48     49 def restore(srgb):    50     return tuple(map(lambda x: int(x * 255.0), srgb))    51     52 def scale(rgb):    53     return tuple(map(lambda x: x / 255.0, rgb))    54     55 def invert(srgb):    56     return tuple(map(lambda x: 1.0 - x, srgb))    57     58 # Colour distribution functions.    59     60 def combination(rgb):    61     62     "Return the colour distribution for 'rgb'."    63     64     # Get the colour with components scaled from 0 to 1, plus the inverted    65     # component values.    66     67     rgb = scale(rgb)    68     rgbi = invert(rgb)    69     pairs = zip(rgbi, rgb)    70     71     # For each corner of the colour cube (primary and secondary colours plus    72     # black and white), calculate the corner value's contribution to the    73     # input colour.    74     75     d = []    76     for corner in corners:    77         rs, gs, bs = scale(corner)    78     79         # Obtain inverted channel values where corner channels are low;    80         # obtain original channel values where corner channels are high.    81     82         d.append((pairs[0][int(rs)] * pairs[1][int(gs)] * pairs[2][int(bs)], corner))    83     84     # Balance the corner contributions.    85     86     return balance(d)    87     88 def complements(rgb):    89     90     "Return 'rgb' and its complement."    91     92     r, g, b = rgb    93     return rgb, restore(invert(scale(rgb)))    94     95 def balance(d):    96     97     """    98     Balance distribution 'd', cancelling opposing values and their complements    99     and replacing their common contributions with black and white contributions.   100     """   101    102     d = dict([(value, f) for f, value in d])   103     for primary, secondary in map(complements, [(0, 0, 0), (255, 0, 0), (0, 255, 0), (0, 0, 255)]):   104         common = min(d[primary], d[secondary])   105         d[primary] -= common   106         d[secondary] -= common   107     return [(f, value) for value, f in d.items()]   108    109 def combine(d):   110    111     "Combine distribution 'd' to get a colour value."   112    113     out = [0, 0, 0]   114     for v, rgb in d:   115         out[0] += v * rgb[0]   116         out[1] += v * rgb[1]   117         out[2] += v * rgb[2]   118     return out   119    120 def pattern(rgb, chosen=None):   121    122     """   123     Obtain a sorted colour distribution for 'rgb', optionally limited to any   124     specified 'chosen' colours.   125     """   126    127     l = [(f, value) for f, value in combination(rgb) if not chosen or value in chosen]   128     l.sort(reverse=True)   129     return l   130    131 def get_value(rgb, chosen=None, fail=False):   132    133     """   134     Get an output colour for 'rgb', optionally limited to any specified 'chosen'   135     colours. If 'fail' is set to a true value, return None if the colour cannot   136     be expressed using any of the chosen colours.   137     """   138    139     l = pattern(rgb, chosen)   140     limit = sum([f for f, c in l])   141     if not limit:   142         if fail:   143             return None   144         else:   145             return l[randrange(0, len(l))][1]   146    147     choose = random() * limit   148     threshold = 0   149     for f, c in l:   150         threshold += f   151         if choose < threshold:   152             return c   153     return c   154    155 # Colour processing operations.   156    157 def sign(x):   158     return x >= 0 and 1 or -1   159    160 def saturate_rgb(rgb, exp):   161     return tuple([saturate_value(x, exp) for x in rgb])   162    163 def saturate_value(x, exp):   164     return int(127.5 + sign(x - 127.5) * 127.5 * pow(abs(x - 127.5) / 127.5, exp))   165    166 def amplify_rgb(rgb, exp):   167     return tuple([amplify_value(x, exp) for x in rgb])   168    169 def amplify_value(x, exp):   170     return int(pow(x / 255.0, exp) * 255.0)   171    172 # Image operations.   173    174 def get_colours(im, y):   175    176     "Get a colour distribution from image 'im' for the row 'y'."   177    178     width, height = im.size   179     c = {}   180     for x in range(0, width):   181         rgb = im.getpixel((x, y))   182    183         # Sum the colour probabilities.   184    185         for f, value in combination(rgb):   186             if not c.has_key(value):   187                 c[value] = f   188             else:   189                 c[value] += f   190    191     d = [(n/width, value) for value, n in c.items()]   192     d.sort(reverse=True)   193     return d   194    195 def get_combinations(c, n):   196    197     """   198     Get combinations of colours from 'c' of size 'n' in decreasing order of   199     probability.   200     """   201    202     all = []   203     for l in itertools.combinations(c, n):   204         total = 0   205         for f, value in l:   206             total += f   207         all.append((total, l))   208     all.sort(reverse=True)   209     return [l for total, l in all]   210    211 def test():   212    213     "Generate slices of the colour cube."   214    215     size = 512   216     for r in (0, 63, 127, 191, 255):   217         im = PIL.Image.new("RGB", (size, size))   218         for g in range(0, size):   219             for b in range(0, size):   220                 value = get_value((r, (g * 256) / size, (b * 256 / size)))   221                 im.putpixel((g, b), value)   222         im.save("rgb%d.png" % r)   223    224 def test_flat(rgb):   225    226     "Generate a flat image for the colour 'rgb'."   227    228     size = 64   229     im = PIL.Image.new("RGB", (size, size))   230     for y in range(0, size):   231         for x in range(0, size):   232             im.putpixel((x, y), get_value(rgb))   233     im.save("rgb%02d%02d%02d.png" % rgb)   234    235 def rotate_and_scale(exif, im, width, height, rotate):   236    237     """   238     Using the given 'exif' information, rotate and scale image 'im' given the   239     indicated 'width' and 'height' constraints and any explicit 'rotate'   240     indication. The returned image will be within the given 'width' and   241     'height', filling either or both, and preserve its original aspect ratio.   242     """   243    244     if rotate or exif and exif["Image Orientation"].values == [6L]:   245         im = im.rotate(270)   246    247     w, h = im.size   248     if w > h:   249         height = (width * h) / w   250     else:   251         width = (height * w) / h   252    253     return im.resize((width, height))   254    255 def count_colours(im, colours):   256    257     """   258     Count colours on each row of image 'im', returning a tuple indicating the   259     first row with more than the given number of 'colours' together with the   260     found colours; otherwise returning None.   261     """   262    263     width, height = im.size   264    265     for y in range(0, height):   266         l = set()   267         for x in range(0, width):   268             l.add(im.getpixel((x, y)))   269         if len(l) > colours:   270             return (y, l)   271     return None   272    273 def process_image(pim, saturate, desaturate, darken, brighten):   274    275     """   276     Process image 'pim' using the given options: 'saturate', 'desaturate',   277     'darken', 'brighten'.   278     """   279    280     width, height = pim.size   281     im = SimpleImage(list(pim.getdata()), pim.size)   282    283     if saturate or desaturate or darken or brighten:   284         for y in range(0, height):   285             for x in range(0, width):   286                 rgb = im.getpixel((x, y))   287                 if saturate or desaturate:   288                     rgb = saturate_rgb(rgb, saturate and 0.5 / saturate or 2 * desaturate)   289                 if darken or brighten:   290                     rgb = amplify_rgb(rgb, brighten and 0.5 / brighten or 2 * darken)   291                 im.putpixel((x, y), rgb)   292    293     pim.putdata(im.getdata())   294    295 def preview_image(pim, half_resolution_preview=False):   296    297     "Return a preview copy of image 'pim'."   298    299     width, height = pim.size   300     imp = pim.copy()   301     im = SimpleImage(list(pim.getdata()), pim.size)   302     step = half_resolution_preview and 2 or 1   303    304     for y in range(0, height):   305         for x in range(0, width):   306             rgb = im.getpixel((x, y))   307             value = get_value(rgb)   308             im.putpixel((x, y), value)   309             if half_resolution_preview:   310                 im.putpixel((x+1, y), value)   311    312     imp.putdata(im.getdata())   313     return imp   314    315 def convert_image(pim):   316    317     "Convert image 'pim' to an appropriate output representation."   318    319     width, height = pim.size   320     im = SimpleImage(list(pim.getdata()), pim.size)   321    322     for y in range(0, height):   323         c = get_colours(im, y)   324    325         for l in get_combinations(c, 4):   326             most = [value for f, value in l]   327             for x in range(0, width):   328                 rgb = im.getpixel((x, y))   329                 value = get_value(rgb, most, True)   330                 if value is None:   331                     break # try next combination   332             else:   333                 break # use this combination   334         else:   335             most = [value for f, value in c[:4]] # use the first four   336    337         for x in range(0, width):   338             rgb = im.getpixel((x, y))   339             value = get_value(rgb, most)   340             im.putpixel((x, y), value)   341    342             if x < width - 1:   343                 rgbn = im.getpixel((x+1, y))   344                 rgbn = tuple(map(lambda i: clip(i[0] + (i[1] - i[2]) / 4.0), zip(rgbn, rgb, value)))   345                 im.putpixel((x+1, y), rgbn)   346    347             if y < height - 1:   348                 rgbn = im.getpixel((x, y+1))   349                 rgbn = tuple(map(lambda i: clip(i[0] + (i[1] - i[2]) / 2.0), zip(rgbn, rgb, value)))   350                 im.putpixel((x, y+1), rgbn)   351    352     pim.putdata(im.getdata())   353    354 def get_float(options, flag):   355     try:   356         i = options.index(flag)   357         if i+1 < len(options) and options[i+1].isdigit():   358             return float(options[i+1])   359         else:   360             return 1.0   361     except ValueError:   362         return 0.0   363    364 class SimpleImage:   365    366     "An image behaving like PIL.Image."   367    368     def __init__(self, data, size):   369         self.data = data   370         self.width, self.height = self.size = size   371    372     def copy(self):   373         return SimpleImage(self.data[:], self.size)   374    375     def getpixel(self, xy):   376         x, y = xy   377         return self.data[y * self.width + x]   378    379     def putpixel(self, xy, value):   380         x, y = xy   381         self.data[y * self.width + x] = value   382    383     def getdata(self):   384         return self.data   385    386 # Main program.   387    388 if __name__ == "__main__":   389    390     # Test options.   391    392     if "--test" in sys.argv:   393         test()   394         sys.exit(0)   395     elif "--test-flat" in sys.argv:   396         test_flat((120, 40, 60))   397         sys.exit(0)   398     elif "--help" in sys.argv:   399         print >>sys.stderr, """\   400 Usage: %s <input filename> <output filename> [ <options> ]   401    402 Options are...   403    404 -s - Saturate the input image (can be followed by a float, default 1.0)   405 -d - Desaturate the input image (can be followed by a float, default 1.0)   406 -D - Darken the input image (can be followed by a float, default 1.0)   407 -B - Brighten the input image (can be followed by a float, default 1.0)   408    409 -r - Rotate the input image clockwise   410 -p - Generate a separate preview image   411 -h - Make the preview image with half horizontal resolution (MODE 2)   412 -v - Verify the output image (loaded if -n is given)   413 -n - Generate no output image   414 """ % split(sys.argv[0])[1]   415         sys.exit(1)   416    417     width = 320   418     height = 256   419    420     input_filename, output_filename = sys.argv[1:3]   421     basename, ext = splitext(output_filename)   422     preview_filename = "".join([basename + "_preview", ext])   423    424     options = sys.argv[3:]   425    426     # Preprocessing options that can be repeated for extra effect.   427    428     saturate = get_float(options, "-s")   429     desaturate = get_float(options, "-d")   430     darken = get_float(options, "-D")   431     brighten = get_float(options, "-B")   432    433     # General output options.   434    435     rotate = "-r" in options   436     preview = "-p" in options   437     half_resolution_preview = "-h" in options   438     verify = "-v" in options   439     no_normal_output = "-n" in options   440     make_image = not no_normal_output   441    442     # Load the input image if requested.   443    444     if make_image or preview:   445         exif = EXIF.process_file(open(input_filename))   446         im = PIL.Image.open(input_filename).convert("RGB")   447         im = rotate_and_scale(exif, im, width, height, rotate)   448    449         process_image(im, saturate, desaturate, darken, brighten)   450    451     # Generate a preview if requested.   452    453     if preview:   454         preview_image(im, half_resolution_preview).save(preview_filename)   455    456     # Generate an output image if requested.   457    458     if make_image:   459         convert_image(im)   460         im.save(output_filename)   461    462     # Verify the output image (which may be loaded) if requested.   463    464     if verify:   465         if no_normal_output:   466             im = PIL.Image.open(output_filename).convert("RGB")   467    468         result = count_colours(im, 4)   469         if result is not None:   470             y, colours = result   471             print "Image %s: row %d has the following colours: %s" % (output_filename, y, "; ".join([repr(c) for c in colours]))   472    473 # vim: tabstop=4 expandtab shiftwidth=4