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