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