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