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 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 choose = random() * limit 174 threshold = 0 175 for f, c in l: 176 threshold += f 177 if choose < threshold: 178 return c 179 return c 180 181 # Colour processing operations. 182 183 def sign(x): 184 return x >= 0 and 1 or -1 185 186 def saturate_rgb(rgb, exp): 187 return tuple([saturate_value(x, exp) for x in rgb]) 188 189 def saturate_value(x, exp): 190 return int(127.5 + sign(x - 127.5) * 127.5 * pow(abs(x - 127.5) / 127.5, exp)) 191 192 def amplify_rgb(rgb, exp): 193 return tuple([amplify_value(x, exp) for x in rgb]) 194 195 def amplify_value(x, exp): 196 return int(pow(x / 255.0, exp) * 255.0) 197 198 # Image operations. 199 200 def get_colours(im, y): 201 202 "Get a colour distribution from image 'im' for the row 'y'." 203 204 width, height = im.size 205 c = {} 206 for x in range(0, width): 207 rgb = im.getpixel((x, y)) 208 209 # Sum the colour probabilities. 210 211 for f, value in combination(rgb): 212 if not c.has_key(value): 213 c[value] = f 214 else: 215 c[value] += f 216 217 c = [(n/width, value) for value, n in c.items()] 218 c.sort(reverse=True) 219 return c 220 221 def test(): 222 223 "Generate slices of the colour cube." 224 225 size = 512 226 for r in (0, 63, 127, 191, 255): 227 im = PIL.Image.new("RGB", (size, size)) 228 for g in range(0, size): 229 for b in range(0, size): 230 value = get_value((r, (g * 256) / size, (b * 256 / size))) 231 im.putpixel((g, b), value) 232 im.save("rgb%d.png" % r) 233 234 def test_flat(rgb): 235 236 "Generate a flat image for the colour 'rgb'." 237 238 size = 64 239 im = PIL.Image.new("RGB", (size, size)) 240 for y in range(0, size): 241 for x in range(0, size): 242 im.putpixel((x, y), get_value(rgb)) 243 im.save("rgb%02d%02d%02d.png" % rgb) 244 245 def rotate_and_scale(exif, im, width, height, rotate): 246 247 """ 248 Using the given 'exif' information, rotate and scale image 'im' given the 249 indicated 'width' and 'height' constraints and any explicit 'rotate' 250 indication. The returned image will be within the given 'width' and 251 'height', filling either or both, and preserve its original aspect ratio. 252 """ 253 254 if rotate or exif and exif["Image Orientation"].values == [6L]: 255 im = im.rotate(270) 256 257 w, h = im.size 258 if w > h: 259 height = (width * h) / w 260 else: 261 width = (height * w) / h 262 263 return im.resize((width, height)) 264 265 def count_colours(im, colours): 266 267 """ 268 Count colours on each row of image 'im', returning a tuple indicating the 269 first row with more than the given number of 'colours' together with the 270 found colours; otherwise returning None. 271 """ 272 273 width, height = im.size 274 for y in range(0, height): 275 l = set() 276 for x in range(0, width): 277 l.add(im.getpixel((x, y))) 278 if len(l) > colours: 279 return (y, l) 280 return None 281 282 # Main program. 283 284 if __name__ == "__main__": 285 286 # Test options. 287 288 if "--test" in sys.argv: 289 test() 290 sys.exit(0) 291 elif "--test-flat" in sys.argv: 292 test_flat((120, 40, 60)) 293 sys.exit(0) 294 elif "--help" in sys.argv: 295 print >>sys.stderr, """\ 296 Usage: %s <input filename> <output filename> [ <options> ] 297 298 Options are... 299 300 -s - Saturate the input image (can be repeated) 301 -d - Desaturate the input image (can be repeated) 302 -D - Darken the input image (can be repeated) 303 -B - Brighten the input image (can be repeated) 304 -2 - Square/diminish the bright corner colour contributions (experimental) 305 306 -r - Rotate the input image clockwise 307 -p - Generate a separate preview image 308 -h - Make the preview image with half horizontal resolution (MODE 2) 309 -v - Verify the output image (loaded if -n is given) 310 -n - Generate no output image 311 """ % split(sys.argv[0])[1] 312 sys.exit(1) 313 314 width = 320 315 height = 256 316 317 input_filename, output_filename = sys.argv[1:3] 318 basename, ext = splitext(output_filename) 319 preview_filename = "".join([basename + "_preview", ext]) 320 321 options = sys.argv[3:] 322 323 # Preprocessing options that can be repeated for extra effect. 324 325 saturate = options.count("-s") 326 desaturate = options.count("-d") 327 darken = options.count("-D") 328 brighten = options.count("-B") 329 330 # Experimental colour distribution modification. 331 332 use_square = "-2" in options 333 if use_square: 334 extra = square 335 else: 336 extra = (lambda x: x) 337 338 # General output options. 339 340 rotate = "-r" in options 341 preview = "-p" in options 342 half_resolution_preview = "-h" in options 343 verify = "-v" in options 344 no_normal_output = "-n" in options 345 make_image = not no_normal_output 346 347 # Load the input image if requested. 348 349 if make_image or preview: 350 exif = EXIF.process_file(open(input_filename)) 351 im = PIL.Image.open(input_filename).convert("RGB") 352 im = rotate_and_scale(exif, im, width, height, rotate) 353 354 width, height = im.size 355 356 if saturate or desaturate or darken or brighten: 357 for y in range(0, height): 358 for x in range(0, width): 359 rgb = im.getpixel((x, y)) 360 if saturate or desaturate: 361 rgb = saturate_rgb(rgb, saturate and math.pow(0.5, saturate) or math.pow(2, desaturate)) 362 if darken or brighten: 363 rgb = amplify_rgb(rgb, brighten and math.pow(0.5, brighten) or math.pow(2, darken)) 364 im.putpixel((x, y), rgb) 365 366 # Generate a preview if requested. 367 368 if preview: 369 imp = im.copy() 370 step = half_resolution_preview and 2 or 1 371 for y in range(0, height): 372 for x in range(0, width, step): 373 rgb = imp.getpixel((x, y)) 374 value = get_value(rgb) 375 imp.putpixel((x, y), value) 376 if half_resolution_preview: 377 imp.putpixel((x+1, y), value) 378 379 imp.save(preview_filename) 380 381 # Generate an output image if requested. 382 383 if make_image: 384 for y in range(0, height): 385 c = get_colours(im, y) 386 most = [value for n, value in c[:4]] 387 least = [value for n, value in c[4:]] 388 389 for x in range(0, width): 390 rgb = im.getpixel((x, y)) 391 value = get_value(rgb, most) 392 im.putpixel((x, y), value) 393 394 if y < height - 1: 395 rgbn = im.getpixel((x, y+1)) 396 rgbn = tuple(map(lambda i: clip(i[0] + i[1] - i[2]), zip(rgbn, rgb, value))) 397 im.putpixel((x, y+1), rgbn) 398 399 im.save(output_filename) 400 401 # Verify the output image (which may be loaded) if requested. 402 403 if verify: 404 if no_normal_output: 405 im = PIL.Image.open(output_filename).convert("RGB") 406 407 result = count_colours(im, 4) 408 if result is not None: 409 y, colours = result 410 print "Image %s: row %d has the following colours: %s" % (output_filename, y, "; ".join([repr(c) for c in colours])) 411 412 # vim: tabstop=4 expandtab shiftwidth=4