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