PaletteOptimiser

Changeset

56:8fd84d0ba366
2015-10-07 Paul Boddie raw files shortlog changelog graph Added documentation and copyright/licensing details.
optimiser.py (file)
     1.1 --- a/optimiser.py	Wed Oct 07 00:49:40 2015 +0200
     1.2 +++ b/optimiser.py	Wed Oct 07 15:55:52 2015 +0200
     1.3 @@ -1,10 +1,29 @@
     1.4  #!/usr/bin/env python
     1.5  
     1.6 -from random import random, randrange
     1.7 -from os.path import splitext
     1.8 +"""
     1.9 +Convert and optimise images for display in an Acorn Electron MODE 1 variant
    1.10 +with four colours per line but eight colours available for selection for each
    1.11 +line.
    1.12 +
    1.13 +Copyright (C) 2015 Paul Boddie <paul@boddie.org.uk>
    1.14 +
    1.15 +This program is free software; you can redistribute it and/or modify it under
    1.16 +the terms of the GNU General Public License as published by the Free Software
    1.17 +Foundation; either version 3 of the License, or (at your option) any later
    1.18 +version.
    1.19 +
    1.20 +This program is distributed in the hope that it will be useful, but WITHOUT ANY
    1.21 +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
    1.22 +PARTICULAR PURPOSE.  See the GNU General Public License for more details.
    1.23 +
    1.24 +You should have received a copy of the GNU General Public License along
    1.25 +with this program.  If not, see <http://www.gnu.org/licenses/>.
    1.26 +"""
    1.27 +
    1.28 +from random import random
    1.29 +from os.path import split, splitext
    1.30  import EXIF
    1.31  import PIL.Image
    1.32 -import itertools
    1.33  import math
    1.34  import sys
    1.35  
    1.36 @@ -13,6 +32,8 @@
    1.37      (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255)
    1.38      ]
    1.39  
    1.40 +# Basic colour operations.
    1.41 +
    1.42  def within(v, lower, upper):
    1.43      return min(max(v, lower), upper)
    1.44  
    1.45 @@ -41,25 +62,56 @@
    1.46  def invert(srgb):
    1.47      return tuple(map(lambda x: 1.0 - x, srgb))
    1.48  
    1.49 +# Colour distribution functions.
    1.50 +
    1.51  cache = {}
    1.52  
    1.53  def combination(rgb):
    1.54 +
    1.55 +    "Return the colour distribution for 'rgb'."
    1.56 +
    1.57      if not cache.has_key(rgb):
    1.58 +
    1.59 +        # Get the colour with components scaled from 0 to 1, plus the inverted
    1.60 +        # component values.
    1.61 +
    1.62          rgb = square(scale(rgb))
    1.63          rgbi = invert(rgb)
    1.64          pairs = zip(rgbi, rgb)
    1.65 +
    1.66 +        # For each corner of the colour cube (primary and secondary colours plus
    1.67 +        # black and white), calculate the corner value's contribution to the
    1.68 +        # input colour.
    1.69 +
    1.70          d = []
    1.71          for corner in corners:
    1.72              rs, gs, bs = scale(corner)
    1.73 +
    1.74 +            # Obtain inverted channel values where corner channels are low;
    1.75 +            # obtain original channel values where corner channels are high.
    1.76 +
    1.77              d.append((pairs[0][int(rs)] * pairs[1][int(gs)] * pairs[2][int(bs)], corner))
    1.78 +
    1.79 +        # Balance the corner contributions.
    1.80 +
    1.81          cache[rgb] = balance(d)
    1.82 +
    1.83      return cache[rgb]
    1.84  
    1.85  def complements(rgb):
    1.86 +
    1.87 +    "Return 'rgb' and its complement."
    1.88 +
    1.89      r, g, b = rgb
    1.90      return rgb, restore(invert(scale(rgb)))
    1.91  
    1.92  def balance(d):
    1.93 +
    1.94 +    """
    1.95 +    Balance distribution 'd', cancelling opposing values and their complements
    1.96 +    and replacing their common contributions with black and white contributions.
    1.97 +    """
    1.98 +
    1.99      d = dict([(value, f) for f, value in d])
   1.100      for primary, secondary in map(complements, [(255, 0, 0), (0, 255, 0), (0, 0, 255)]):
   1.101          common = min(d[primary], d[secondary])
   1.102 @@ -70,6 +122,12 @@
   1.103      return [(f, value) for value, f in d.items()]
   1.104  
   1.105  def compensate(d, chosen):
   1.106 +
   1.107 +    """
   1.108 +    Compensate distribution 'd' for the given 'chosen' colours, reducing chosen
   1.109 +    colour contributions where their complements are not part of the chosen set.
   1.110 +    """
   1.111 +
   1.112      dd = dict([(value, f) for f, value in d])
   1.113      for f, value in d:
   1.114          if value in chosen:
   1.115 @@ -80,6 +138,9 @@
   1.116      return [(f, value) for value, f in dd.items() if value in chosen]
   1.117  
   1.118  def combine(d):
   1.119 +
   1.120 +    "Combine distribution 'd' to get a colour value."
   1.121 +
   1.122      out = [0, 0, 0]
   1.123      for v, rgb in d:
   1.124          out[0] += v * rgb[0]
   1.125 @@ -88,6 +149,12 @@
   1.126      return out
   1.127  
   1.128  def pattern(rgb, chosen=None):
   1.129 +
   1.130 +    """
   1.131 +    Obtain a sorted colour distribution for 'rgb', optionally limited to any
   1.132 +    specified 'chosen' colours.
   1.133 +    """
   1.134 +
   1.135      l = combination(rgb)
   1.136      if chosen:
   1.137          l = compensate(l, chosen)
   1.138 @@ -95,6 +162,12 @@
   1.139      return l
   1.140  
   1.141  def get_value(rgb, chosen=None):
   1.142 +
   1.143 +    """
   1.144 +    Get an output colour for 'rgb', optionally limited to any specified 'chosen'
   1.145 +    colours.
   1.146 +    """
   1.147 +
   1.148      l = pattern(rgb, chosen)
   1.149      limit = sum([f for f, c in l])
   1.150      choose = random() * limit
   1.151 @@ -105,6 +178,8 @@
   1.152              return c
   1.153      return c
   1.154  
   1.155 +# Colour processing operations.
   1.156 +
   1.157  def sign(x):
   1.158      return x >= 0 and 1 or -1
   1.159  
   1.160 @@ -120,7 +195,12 @@
   1.161  def amplify_value(x, exp):
   1.162      return int(pow(x / 255.0, exp) * 255.0)
   1.163  
   1.164 +# Image operations.
   1.165 +
   1.166  def get_colours(im, y):
   1.167 +
   1.168 +    "Get a colour distribution from image 'im' for the row 'y'."
   1.169 +
   1.170      width, height = im.size
   1.171      c = {}
   1.172      for x in range(0, width):
   1.173 @@ -139,6 +219,9 @@
   1.174      return c
   1.175  
   1.176  def test():
   1.177 +
   1.178 +    "Generate slices of the colour cube."
   1.179 +
   1.180      size = 512
   1.181      for r in (0, 63, 127, 191, 255):
   1.182          im = PIL.Image.new("RGB", (size, size))
   1.183 @@ -149,6 +232,9 @@
   1.184          im.save("rgb%d.png" % r)
   1.185  
   1.186  def test_flat(rgb):
   1.187 +
   1.188 +    "Generate a flat image for the colour 'rgb'."
   1.189 +
   1.190      size = 64
   1.191      im = PIL.Image.new("RGB", (size, size))
   1.192      for y in range(0, size):
   1.193 @@ -157,6 +243,14 @@
   1.194      im.save("rgb%02d%02d%02d.png" % rgb)
   1.195  
   1.196  def rotate_and_scale(exif, im, width, height, rotate):
   1.197 +
   1.198 +    """
   1.199 +    Using the given 'exif' information, rotate and scale image 'im' given the
   1.200 +    indicated 'width' and 'height' constraints and any explicit 'rotate'
   1.201 +    indication. The returned image will be within the given 'width' and
   1.202 +    'height', filling either or both, and preserve its original aspect ratio.
   1.203 +    """
   1.204 +
   1.205      if rotate or exif and exif["Image Orientation"].values == [6L]:
   1.206          im = im.rotate(270)
   1.207  
   1.208 @@ -169,6 +263,13 @@
   1.209      return im.resize((width, height))
   1.210  
   1.211  def count_colours(im, colours):
   1.212 +
   1.213 +    """
   1.214 +    Count colours on each row of image 'im', returning a tuple indicating the
   1.215 +    first row with more than the given number of 'colours' together with the
   1.216 +    found colours; otherwise returning None.
   1.217 +    """
   1.218 +
   1.219      width, height = im.size
   1.220      for y in range(0, height):
   1.221          l = set()
   1.222 @@ -178,13 +279,37 @@
   1.223              return (y, l)
   1.224      return None
   1.225  
   1.226 +# Main program.
   1.227 +
   1.228  if __name__ == "__main__":
   1.229 +
   1.230 +    # Test options.
   1.231 +
   1.232      if "--test" in sys.argv:
   1.233          test()
   1.234          sys.exit(0)
   1.235      elif "--test-flat" in sys.argv:
   1.236          test_flat((120, 40, 60))
   1.237          sys.exit(0)
   1.238 +    elif "--help" in sys.argv:
   1.239 +        print >>sys.stderr, """\
   1.240 +Usage: %s <input filename> <output filename> [ <options> ]
   1.241 +
   1.242 +Options are...
   1.243 +
   1.244 +-s - Saturate the input image (can be repeated)
   1.245 +-d - Desaturate the input image (can be repeated)
   1.246 +-D - Darken the input image (can be repeated)
   1.247 +-B - Brighten the input image (can be repeated)
   1.248 +-2 - Square/diminish the bright corner colour contributions (experimental)
   1.249 +
   1.250 +-r - Rotate the input image clockwise
   1.251 +-p - Generate a separate preview image
   1.252 +-h - Make the preview image with half horizontal resolution (MODE 2)
   1.253 +-v - Verify the output image (loaded if -n is given)
   1.254 +-n - Generate no output image
   1.255 +""" % split(sys.argv[0])[1]
   1.256 +        sys.exit(1)
   1.257  
   1.258      width = 320
   1.259      height = 256
   1.260 @@ -195,18 +320,28 @@
   1.261  
   1.262      options = sys.argv[3:]
   1.263  
   1.264 -    rotate = "-r" in options
   1.265 +    # Preprocessing options that can be repeated for extra effect.
   1.266 +
   1.267      saturate = options.count("-s")
   1.268      desaturate = options.count("-d")
   1.269      darken = options.count("-D")
   1.270      brighten = options.count("-B")
   1.271 +
   1.272 +    # Experimental colour distribution modification.
   1.273 +
   1.274      square = "-2" in options and square or (lambda x: x)
   1.275 +
   1.276 +    # General output options.
   1.277 +
   1.278 +    rotate = "-r" in options
   1.279      preview = "-p" in options
   1.280      half_resolution_preview = "-h" in options
   1.281      verify = "-v" in options
   1.282      no_normal_output = "-n" in options
   1.283      make_image = not no_normal_output
   1.284  
   1.285 +    # Load the input image if requested.
   1.286 +
   1.287      if make_image or preview:
   1.288          exif = EXIF.process_file(open(input_filename))
   1.289          im = PIL.Image.open(input_filename).convert("RGB")
   1.290 @@ -224,6 +359,8 @@
   1.291                          rgb = amplify_rgb(rgb, brighten and math.pow(0.5, brighten) or math.pow(2, darken))
   1.292                      im.putpixel((x, y), rgb)
   1.293  
   1.294 +    # Generate a preview if requested.
   1.295 +
   1.296      if preview:
   1.297          imp = im.copy()
   1.298          step = half_resolution_preview and 2 or 1
   1.299 @@ -237,6 +374,8 @@
   1.300  
   1.301          imp.save(preview_filename)
   1.302  
   1.303 +    # Generate an output image if requested.
   1.304 +
   1.305      if make_image:
   1.306          for y in range(0, height):
   1.307              c = get_colours(im, y)
   1.308 @@ -255,6 +394,8 @@
   1.309  
   1.310          im.save(output_filename)
   1.311  
   1.312 +    # Verify the output image (which may be loaded) if requested.
   1.313 +
   1.314      if verify:
   1.315          if no_normal_output:
   1.316              im = PIL.Image.open(output_filename).convert("RGB")
   1.317 @@ -262,6 +403,6 @@
   1.318          result = count_colours(im, 4)
   1.319          if result is not None:
   1.320              y, colours = result
   1.321 -            print "Row %d has the following colours: %s" % (y, "; ".join([repr(c) for c in colours]))
   1.322 +            print "Image %s: row %d has the following colours: %s" % (output_filename, y, "; ".join([repr(c) for c in colours]))
   1.323  
   1.324  # vim: tabstop=4 expandtab shiftwidth=4