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 = square(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 square = "-2" in options and square or (lambda x: x) 333 334 # General output options. 335 336 rotate = "-r" in options 337 preview = "-p" in options 338 half_resolution_preview = "-h" in options 339 verify = "-v" in options 340 no_normal_output = "-n" in options 341 make_image = not no_normal_output 342 343 # Load the input image if requested. 344 345 if make_image or preview: 346 exif = EXIF.process_file(open(input_filename)) 347 im = PIL.Image.open(input_filename).convert("RGB") 348 im = rotate_and_scale(exif, im, width, height, rotate) 349 350 width, height = im.size 351 352 if saturate or desaturate or darken or brighten: 353 for y in range(0, height): 354 for x in range(0, width): 355 rgb = im.getpixel((x, y)) 356 if saturate or desaturate: 357 rgb = saturate_rgb(rgb, saturate and math.pow(0.5, saturate) or math.pow(2, desaturate)) 358 if darken or brighten: 359 rgb = amplify_rgb(rgb, brighten and math.pow(0.5, brighten) or math.pow(2, darken)) 360 im.putpixel((x, y), rgb) 361 362 # Generate a preview if requested. 363 364 if preview: 365 imp = im.copy() 366 step = half_resolution_preview and 2 or 1 367 for y in range(0, height): 368 for x in range(0, width, step): 369 rgb = imp.getpixel((x, y)) 370 value = get_value(rgb) 371 imp.putpixel((x, y), value) 372 if half_resolution_preview: 373 imp.putpixel((x+1, y), value) 374 375 imp.save(preview_filename) 376 377 # Generate an output image if requested. 378 379 if make_image: 380 for y in range(0, height): 381 c = get_colours(im, y) 382 most = [value for n, value in c[:4]] 383 least = [value for n, value in c[4:]] 384 385 for x in range(0, width): 386 rgb = im.getpixel((x, y)) 387 value = get_value(rgb, most) 388 im.putpixel((x, y), value) 389 390 if y < height - 1: 391 rgbn = im.getpixel((x, y+1)) 392 rgbn = tuple(map(lambda i: clip(i[0] + i[1] - i[2]), zip(rgbn, rgb, value))) 393 im.putpixel((x, y+1), rgbn) 394 395 im.save(output_filename) 396 397 # Verify the output image (which may be loaded) if requested. 398 399 if verify: 400 if no_normal_output: 401 im = PIL.Image.open(output_filename).convert("RGB") 402 403 result = count_colours(im, 4) 404 if result is not None: 405 y, colours = result 406 print "Image %s: row %d has the following colours: %s" % (output_filename, y, "; ".join([repr(c) for c in colours])) 407 408 # vim: tabstop=4 expandtab shiftwidth=4