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(pim, saturate, desaturate, darken, brighten): 290 291 """ 292 Process image 'pim' using the given options: 'saturate', 'desaturate', 293 'darken', 'brighten'. 294 """ 295 296 width, height = pim.size 297 im = SimpleImage(list(pim.getdata()), pim.size) 298 299 if saturate or desaturate or darken or brighten: 300 y = 0 301 while y < height: 302 x = 0 303 while x < width: 304 rgb = im.getpixel((x, y)) 305 if saturate or desaturate: 306 rgb = saturate_rgb(rgb, saturate and 0.5 / saturate or 2 * desaturate) 307 if darken or brighten: 308 rgb = amplify_rgb(rgb, brighten and 0.5 / brighten or 2 * darken) 309 im.putpixel((x, y), rgb) 310 x += 1 311 y += 1 312 313 pim.putdata(im.getdata()) 314 315 def convert_image(pim, colours): 316 317 "Convert image 'pim' to an appropriate output representation." 318 319 width, height = pim.size 320 im = SimpleImage(list(pim.getdata()), pim.size) 321 322 y = 0 323 while y < height: 324 c = get_colours(im, y) 325 326 suggestions = [] 327 328 for l in get_combinations(c, colours): 329 most = [value for f, value in l] 330 missing = 0 331 332 x = 0 333 while x < width: 334 rgb = im.getpixel((x, y)) 335 value = get_value(rgb, most, True) 336 if value is None: 337 missing += 1 338 x += 1 339 340 if not missing: 341 break # use this combination 342 suggestions.append((missing, l)) 343 344 # Find the most accurate suggestion. 345 346 else: 347 suggestions.sort() 348 most = [value for f, value in suggestions[0][1]] # get the combination 349 350 x = 0 351 while x < width: 352 rgb = im.getpixel((x, y)) 353 value = get_value(rgb, most) 354 im.putpixel((x, y), value) 355 356 if x < width - 1: 357 rgbn = im.getpixel((x+1, y)) 358 rgbn = ( 359 clip(rgbn[0] + (rgb[0] - value[0]) / 4.0), 360 clip(rgbn[1] + (rgb[1] - value[1]) / 4.0), 361 clip(rgbn[2] + (rgb[2] - value[2]) / 4.0) 362 ) 363 im.putpixel((x+1, y), rgbn) 364 365 if y < height - 1: 366 rgbn = im.getpixel((x, y+1)) 367 rgbn = ( 368 clip(rgbn[0] + (rgb[0] - value[0]) / 2.0), 369 clip(rgbn[1] + (rgb[1] - value[1]) / 2.0), 370 clip(rgbn[2] + (rgb[2] - value[2]) / 2.0) 371 ) 372 im.putpixel((x, y+1), rgbn) 373 374 x += 1 375 376 y += 1 377 378 pim.putdata(im.getdata()) 379 380 def get_parameter(options, flag, conversion, default, missing): 381 382 """ 383 From 'options', return any parameter following the given 'flag', applying 384 the 'conversion' which has the given 'default' if no valid parameter is 385 found, or returning the given 'missing' value if the flag does not appear at 386 all. 387 """ 388 389 try: 390 i = options.index(flag) 391 try: 392 return conversion(options[i+1]) 393 except (IndexError, ValueError): 394 return default 395 except ValueError: 396 return missing 397 398 class SimpleImage: 399 400 "An image behaving like PIL.Image." 401 402 def __init__(self, data, size): 403 self.data = data 404 self.width, self.height = self.size = size 405 406 def copy(self): 407 return SimpleImage(self.data[:], self.size) 408 409 def getpixel(self, xy): 410 x, y = xy 411 return self.data[y * self.width + x] 412 413 def putpixel(self, xy, value): 414 x, y = xy 415 self.data[y * self.width + x] = value 416 417 def getdata(self): 418 return self.data 419 420 # Main program. 421 422 if __name__ == "__main__": 423 424 # Test options. 425 426 if "--test" in sys.argv: 427 test() 428 sys.exit(0) 429 elif "--test-flat" in sys.argv: 430 test_flat((120, 40, 60)) 431 sys.exit(0) 432 elif "--help" in sys.argv: 433 print >>sys.stderr, """\ 434 Usage: %s <input filename> <output filename> [ <options> ] 435 436 Options are... 437 438 -W - Indicate the output width (default is 320) 439 -C - Number of colours per scanline (default is 4) 440 441 -s - Saturate the input image (optional float, 1.0 if unspecified) 442 -d - Desaturate the input image (optional float, 1.0 if unspecified) 443 -D - Darken the input image (optional float, 1.0 if unspecified) 444 -B - Brighten the input image (optional float, 1.0 if unspecified) 445 446 -r - Rotate the input image clockwise 447 -p - Generate a separate preview image 448 -h - Make the preview image with half horizontal resolution (MODE 2) 449 -v - Verify the output image (loaded if -n is given) 450 -n - Generate no output image 451 """ % split(sys.argv[0])[1] 452 sys.exit(1) 453 454 base_width = 320 455 height = 256 456 457 input_filename, output_filename = sys.argv[1:3] 458 basename, ext = splitext(output_filename) 459 preview_filename = "".join([basename + "_preview", ext]) 460 461 options = sys.argv[3:] 462 463 # Basic image properties. 464 465 width = get_parameter(options, "-W", int, base_width, base_width) 466 number_of_colours = get_parameter(options, "-C", int, 4, 4) 467 468 # Preprocessing options that employ parameters. 469 470 saturate = get_parameter(options, "-s", float, 1.0, 0.0) 471 desaturate = get_parameter(options, "-d", float, 1.0, 0.0) 472 darken = get_parameter(options, "-D", float, 1.0, 0.0) 473 brighten = get_parameter(options, "-B", float, 1.0, 0.0) 474 475 # General output options. 476 477 rotate = "-r" in options 478 preview = "-p" in options 479 half_resolution_preview = "-h" in options 480 verify = "-v" in options 481 no_normal_output = "-n" in options 482 make_image = not no_normal_output 483 484 # Load the input image if requested. 485 486 if make_image or preview: 487 exif = EXIF.process_file(open(input_filename)) 488 im = PIL.Image.open(input_filename).convert("RGB") 489 im = rotate_and_scale(exif, im, base_width, height, rotate) 490 491 # Scale images to the appropriate width. 492 493 if width != base_width: 494 im = im.resize((width, height)) 495 496 process_image(im, saturate, desaturate, darken, brighten) 497 498 # Generate a preview if requested. 499 500 if preview: 501 imp = im.copy() 502 if half_resolution_preview: 503 imp = imp.resize((width / 2, height)) 504 convert_image(imp, 8) 505 if half_resolution_preview: 506 imp = imp.resize((width, height)) 507 imp.save(preview_filename) 508 509 # Generate an output image if requested. 510 511 if make_image: 512 convert_image(im, number_of_colours) 513 im.save(output_filename) 514 515 # Verify the output image (which may be loaded) if requested. 516 517 if verify: 518 if no_normal_output: 519 im = PIL.Image.open(output_filename).convert("RGB") 520 521 result = count_colours(im, number_of_colours) 522 if result is not None: 523 y, colours = result 524 print "Image %s: row %d has the following colours: %s" % (output_filename, y, "; ".join([repr(c) for c in colours])) 525 526 # vim: tabstop=4 expandtab shiftwidth=4