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