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 sys 29 30 corners = [ 31 (0, 0, 0), (255, 0, 0), (0, 255, 0), (255, 255, 0), 32 (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255) 33 ] 34 35 # Basic colour operations. 36 37 def within(v, lower, upper): 38 return min(max(v, lower), upper) 39 40 def clip(v): 41 return int(within(v, 0, 255)) 42 43 def restore(srgb): 44 r, g, b = srgb 45 return int(r * 255.0), int(g * 255.0), int(b * 255.0) 46 47 def scale(rgb): 48 r, g, b = rgb 49 return r / 255.0, g / 255.0, b / 255.0 50 51 def invert(srgb): 52 r, g, b = srgb 53 return 1.0 - r, 1.0 - g, 1.0 - b 54 55 scaled_corners = map(scale, corners) 56 zipped_corners = zip(corners, scaled_corners) 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 srgb = scale(rgb) 68 rgbi = invert(srgb) 69 pairs = zip(rgbi, srgb) 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, scaled in zipped_corners: 77 rs, gs, bs = scaled 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 bases = [(0, 0, 0), (255, 0, 0), (0, 255, 0), (0, 0, 255)] 96 base_complements = map(complements, bases) 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 base_complements: 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 tuple(map(int, 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 r, g, b = rgb 165 return saturate_value(r, exp), saturate_value(g, exp), saturate_value(b, exp) 166 167 def saturate_value(x, exp): 168 return int(127.5 + sign(x - 127.5) * 127.5 * pow(abs(x - 127.5) / 127.5, exp)) 169 170 def amplify_rgb(rgb, exp): 171 r, g, b = rgb 172 return amplify_value(r, exp), amplify_value(g, exp), amplify_value(b, exp) 173 174 def amplify_value(x, exp): 175 return int(pow(x / 255.0, exp) * 255.0) 176 177 # Image operations. 178 179 def get_colours(im, y): 180 181 "Get a colour distribution from image 'im' for the row 'y'." 182 183 width, height = im.size 184 c = {} 185 x = 0 186 while x < width: 187 rgb = im.getpixel((x, y)) 188 189 # Sum the colour probabilities. 190 191 for f, value in combination(rgb): 192 if not c.has_key(value): 193 c[value] = f 194 else: 195 c[value] += f 196 197 x += 1 198 199 d = [(n/width, value) for value, n in c.items()] 200 d.sort(reverse=True) 201 return d 202 203 def get_combinations(c, n): 204 205 """ 206 Get combinations of colours from 'c' of size 'n' in decreasing order of 207 probability. 208 """ 209 210 all = [] 211 for l in itertools.combinations(c, n): 212 total = 0 213 for f, value in l: 214 total += f 215 all.append((total, l)) 216 all.sort(reverse=True) 217 return [l for total, l in all] 218 219 def test(): 220 221 "Generate slices of the colour cube." 222 223 size = 512 224 for r in (0, 63, 127, 191, 255): 225 im = PIL.Image.new("RGB", (size, size)) 226 for g in range(0, size): 227 for b in range(0, size): 228 value = get_value((r, (g * 256) / size, (b * 256 / size))) 229 im.putpixel((g, b), value) 230 im.save("rgb%d.png" % r) 231 232 def test_flat(rgb): 233 234 "Generate a flat image for the colour 'rgb'." 235 236 size = 64 237 im = PIL.Image.new("RGB", (size, size)) 238 y = 0 239 while y < height: 240 x = 0 241 while x < width: 242 im.putpixel((x, y), get_value(rgb)) 243 x += 1 244 y += 1 245 im.save("rgb%02d%02d%02d.png" % rgb) 246 247 def rotate_and_scale(exif, im, width, height, rotate): 248 249 """ 250 Using the given 'exif' information, rotate and scale image 'im' given the 251 indicated 'width' and 'height' constraints and any explicit 'rotate' 252 indication. The returned image will be within the given 'width' and 253 'height', filling either or both, and preserve its original aspect ratio. 254 """ 255 256 if rotate or exif and exif["Image Orientation"].values == [6L]: 257 im = im.rotate(270) 258 259 w, h = im.size 260 if w > h: 261 height = (width * h) / w 262 else: 263 width = (height * w) / h 264 265 return im.resize((width, height)) 266 267 def count_colours(im, colours): 268 269 """ 270 Count colours on each row of image 'im', returning a tuple indicating the 271 first row with more than the given number of 'colours' together with the 272 found colours; otherwise returning None. 273 """ 274 275 width, height = im.size 276 277 y = 0 278 while y < height: 279 l = set() 280 x = 0 281 while x < width: 282 l.add(im.getpixel((x, y))) 283 x += 1 284 if len(l) > colours: 285 return (y, l) 286 y += 1 287 return None 288 289 def process_image(im, saturate, desaturate, darken, brighten): 290 291 """ 292 Process image 'im' using the given options: 'saturate', 'desaturate', 293 'darken', 'brighten'. 294 """ 295 296 width, height = im.size 297 298 if saturate or desaturate or darken or brighten: 299 y = 0 300 while y < height: 301 x = 0 302 while x < width: 303 rgb = im.getpixel((x, y)) 304 if saturate or desaturate: 305 rgb = saturate_rgb(rgb, saturate and 0.5 / saturate or 2 * desaturate) 306 if darken or brighten: 307 rgb = amplify_rgb(rgb, brighten and 0.5 / brighten or 2 * darken) 308 im.putpixel((x, y), rgb) 309 x += 1 310 y += 1 311 312 def convert_image(im, colours): 313 314 "Convert image 'im' to an appropriate output representation." 315 316 width, height = im.size 317 318 y = 0 319 while y < height: 320 c = get_colours(im, y) 321 322 suggestions = [] 323 324 for l in get_combinations(c, colours): 325 most = [value for f, value in l] 326 missing = 0 327 328 x = 0 329 while x < width: 330 rgb = im.getpixel((x, y)) 331 value = get_value(rgb, most, True) 332 if value is None: 333 missing += 1 334 x += 1 335 336 if not missing: 337 break # use this combination 338 suggestions.append((missing, l)) 339 340 # Find the most accurate suggestion. 341 342 else: 343 suggestions.sort() 344 most = [value for f, value in suggestions[0][1]] # get the combination 345 346 x = 0 347 while x < width: 348 rgb = im.getpixel((x, y)) 349 value = get_value(rgb, most) 350 im.putpixel((x, y), value) 351 352 if x < width - 1: 353 rgbn = im.getpixel((x+1, y)) 354 rgbn = ( 355 clip(rgbn[0] + (rgb[0] - value[0]) / 4.0), 356 clip(rgbn[1] + (rgb[1] - value[1]) / 4.0), 357 clip(rgbn[2] + (rgb[2] - value[2]) / 4.0) 358 ) 359 im.putpixel((x+1, y), rgbn) 360 361 if y < height - 1: 362 rgbn = im.getpixel((x, y+1)) 363 rgbn = ( 364 clip(rgbn[0] + (rgb[0] - value[0]) / 2.0), 365 clip(rgbn[1] + (rgb[1] - value[1]) / 2.0), 366 clip(rgbn[2] + (rgb[2] - value[2]) / 2.0) 367 ) 368 im.putpixel((x, y+1), rgbn) 369 370 x += 1 371 372 y += 1 373 374 def get_parameter(options, flag, conversion, default, missing): 375 376 """ 377 From 'options', return any parameter following the given 'flag', applying 378 the 'conversion' which has the given 'default' if no valid parameter is 379 found, or returning the given 'missing' value if the flag does not appear at 380 all. 381 """ 382 383 try: 384 i = options.index(flag) 385 try: 386 return conversion(options[i+1]) 387 except (IndexError, ValueError): 388 return default 389 except ValueError: 390 return missing 391 392 # Main program. 393 394 if __name__ == "__main__": 395 396 # Test options. 397 398 if "--test" in sys.argv: 399 test() 400 sys.exit(0) 401 elif "--test-flat" in sys.argv: 402 test_flat((120, 40, 60)) 403 sys.exit(0) 404 elif "--help" in sys.argv: 405 print >>sys.stderr, """\ 406 Usage: %s <input filename> <output filename> [ <options> ] 407 408 Options are... 409 410 -W - Indicate the output width (default is 320) 411 -C - Number of colours per scanline (default is 4) 412 413 -s - Saturate the input image (optional float, 1.0 if unspecified) 414 -d - Desaturate the input image (optional float, 1.0 if unspecified) 415 -D - Darken the input image (optional float, 1.0 if unspecified) 416 -B - Brighten the input image (optional float, 1.0 if unspecified) 417 418 -r - Rotate the input image clockwise 419 -p - Generate a separate preview image 420 -h - Make the preview image with half horizontal resolution (MODE 2) 421 -v - Verify the output image (loaded if -n is given) 422 -n - Generate no output image 423 """ % split(sys.argv[0])[1] 424 sys.exit(1) 425 426 base_width = 320 427 height = 256 428 429 input_filename, output_filename = sys.argv[1:3] 430 basename, ext = splitext(output_filename) 431 preview_filename = "".join([basename + "_preview", ext]) 432 433 options = sys.argv[3:] 434 435 # Basic image properties. 436 437 width = get_parameter(options, "-W", int, base_width, base_width) 438 number_of_colours = get_parameter(options, "-C", int, 4, 4) 439 440 # Preprocessing options that employ parameters. 441 442 saturate = get_parameter(options, "-s", float, 1.0, 0.0) 443 desaturate = get_parameter(options, "-d", float, 1.0, 0.0) 444 darken = get_parameter(options, "-D", float, 1.0, 0.0) 445 brighten = get_parameter(options, "-B", float, 1.0, 0.0) 446 447 # General output options. 448 449 rotate = "-r" in options 450 preview = "-p" in options 451 half_resolution_preview = "-h" in options 452 verify = "-v" in options 453 no_normal_output = "-n" in options 454 make_image = not no_normal_output 455 456 # Load the input image if requested. 457 458 if make_image or preview: 459 exif = EXIF.process_file(open(input_filename)) 460 im = PIL.Image.open(input_filename).convert("RGB") 461 im = rotate_and_scale(exif, im, base_width, height, rotate) 462 463 # Scale images to the appropriate width. 464 465 if width != base_width: 466 im = im.resize((width, height)) 467 468 process_image(im, saturate, desaturate, darken, brighten) 469 470 # Generate a preview if requested. 471 472 if preview: 473 imp = im.copy() 474 if half_resolution_preview: 475 imp = imp.resize((width / 2, height)) 476 convert_image(imp, 8) 477 if half_resolution_preview: 478 imp = imp.resize((width, height)) 479 imp.save(preview_filename) 480 481 # Generate an output image if requested. 482 483 if make_image: 484 convert_image(im, number_of_colours) 485 im.save(output_filename) 486 487 # Verify the output image (which may be loaded) if requested. 488 489 if verify: 490 if no_normal_output: 491 im = PIL.Image.open(output_filename).convert("RGB") 492 493 result = count_colours(im, number_of_colours) 494 if result is not None: 495 y, colours = result 496 print "Image %s: row %d has the following colours: %s" % (output_filename, y, "; ".join([repr(c) for c in colours])) 497 498 # vim: tabstop=4 expandtab shiftwidth=4