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