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 import itertools 25 import math 26 import sys 27 28 corners = [ 29 (0, 0, 0), (255, 0, 0), (0, 255, 0), (255, 255, 0), 30 (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255) 31 ] 32 33 # Basic colour operations. 34 35 def within(v, lower, upper): 36 return min(max(v, lower), upper) 37 38 def clip(v): 39 return int(within(v, 0, 255)) 40 41 def distance(rgb1, rgb2): 42 r1, g1, b1 = rgb1 43 r2, g2, b2 = rgb2 44 return math.sqrt(pow(r1 - r2, 2) + pow(g1 - g2, 2) + pow(b1 - b2, 2)) 45 46 def restore(srgb): 47 r, g, b = srgb 48 return int(r * 255.0), int(g * 255.0), int(b * 255.0) 49 50 def scale(rgb): 51 r, g, b = rgb 52 return r / 255.0, g / 255.0, b / 255.0 53 54 def invert(srgb): 55 r, g, b = srgb 56 return 1.0 - r, 1.0 - g, 1.0 - b 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 dd = {} 103 for f, value in d: 104 dd[value] = f 105 for colour in [(0, 0, 0), (255, 0, 0), (0, 255, 0), (0, 0, 255)]: 106 primary, secondary = complements(colour) 107 common = min(dd[primary], dd[secondary]) 108 dd[primary] -= common 109 dd[secondary] -= common 110 d = [] 111 for value, f in dd.items(): 112 d.append((f, value)) 113 return d 114 115 def combine(d): 116 117 "Combine distribution 'd' to get a colour value." 118 119 out = [0, 0, 0] 120 for v, rgb in d: 121 out[0] += v * rgb[0] 122 out[1] += v * rgb[1] 123 out[2] += v * rgb[2] 124 return out 125 126 def pattern(rgb, chosen=None): 127 128 """ 129 Obtain a sorted colour distribution for 'rgb', optionally limited to any 130 specified 'chosen' colours. 131 """ 132 133 l = [] 134 for f, value in combination(rgb): 135 if not chosen or value in chosen: 136 l.append((f, value)) 137 l.sort(reverse=True) 138 return l 139 140 def get_value(rgb, chosen=None, fail=False): 141 142 """ 143 Get an output colour for 'rgb', optionally limited to any specified 'chosen' 144 colours. If 'fail' is set to a true value, raise ValueError if the colour 145 cannot be expressed using any of the chosen colours. 146 """ 147 148 l = pattern(rgb, chosen) 149 limit = 0 150 for f, c in l: 151 limit += f 152 153 if not limit: 154 if fail: 155 raise ValueError 156 else: 157 return l[randrange(0, len(l))][1] 158 159 choose = random() * limit 160 threshold = 0 161 for f, c in l: 162 threshold += f 163 if choose < threshold: 164 return c 165 return c 166 167 # Colour processing operations. 168 169 def sign(x): 170 return x >= 0 and 1 or -1 171 172 def saturate_rgb(rgb, exp): 173 r, g, b = rgb 174 return saturate_value(r, exp), saturate_value(g, exp), saturate_value(b, exp) 175 176 def saturate_value(x, exp): 177 return int(127.5 + sign(x - 127.5) * 127.5 * pow(abs(x - 127.5) / 127.5, exp)) 178 179 def amplify_rgb(rgb, exp): 180 r, g, b = rgb 181 return amplify_value(r, exp), amplify_value(g, exp), amplify_value(b, exp) 182 183 def amplify_value(x, exp): 184 return int(pow(x / 255.0, exp) * 255.0) 185 186 # Image operations. 187 188 def get_colours(im, y): 189 190 "Get a colour distribution from image 'im' for the row 'y'." 191 192 width, height = im.size 193 c = {} 194 for x in range(0, width): 195 rgb = im.getpixel((x, y)) 196 197 # Sum the colour probabilities. 198 199 for f, value in combination(rgb): 200 if not c.has_key(value): 201 c[value] = f 202 else: 203 c[value] += f 204 205 d = [] 206 for value, n in c.items(): 207 d.append((n/width, value)) 208 d.sort(reverse=True) 209 return d 210 211 def get_combinations(c, n): 212 213 """ 214 Get combinations of colours from 'c' of size 'n' in decreasing order of 215 probability. 216 """ 217 218 all = [] 219 for l in itertools.combinations(c, n): 220 total = 0 221 for f, value in l: 222 total += f 223 all.append((total, l)) 224 all.sort(reverse=True) 225 cc = [] 226 for total, l in all: 227 cc.append(l) 228 return cc 229 230 def test_slice(im, size, r): 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 236 def test_flat_slice(im, size, rgb): 237 for y in range(0, size): 238 for x in range(0, size): 239 im.putpixel((x, y), get_value(rgb)) 240 241 def count_colours(im, colours): 242 243 """ 244 Count colours on each row of image 'im', returning a tuple indicating the 245 first row with more than the given number of 'colours' together with the 246 found colours; otherwise returning None. 247 """ 248 249 width, height = im.size 250 251 for y in range(0, height): 252 l = set(im.getdata()[y * width:(y+1) * width]) 253 if len(l) > colours: 254 return (y, l) 255 return None 256 257 def process_image(im, saturate, desaturate, darken, brighten): 258 259 """ 260 Process image 'im' using the given options: 'saturate', 'desaturate', 261 'darken', 'brighten'. 262 """ 263 264 width, height = im.size 265 266 if saturate or desaturate or darken or brighten: 267 for y in range(0, height): 268 for x in range(0, width): 269 rgb = im.getpixel((x, y)) 270 if saturate: 271 rgb = saturate_rgb(rgb, 0.5 / saturate) 272 elif desaturate: 273 rgb = saturate_rgb(rgb, 2 * desaturate) 274 if darken: 275 rgb = amplify_rgb(rgb, 2 * darken) 276 elif brighten: 277 rgb = amplify_rgb(rgb, 0.5 / brighten) 278 im.putpixel((x, y), rgb) 279 280 def preview_image(im, half_resolution_preview=False): 281 282 "Return a preview copy of image 'im'." 283 284 width, height = im.size 285 286 imp = im.copy() 287 step = half_resolution_preview and 2 or 1 288 289 for y in range(0, height): 290 for x in range(0, width, step): 291 rgb = imp.getpixel((x, y)) 292 value = get_value(rgb) 293 imp.putpixel((x, y), value) 294 if half_resolution_preview: 295 imp.putpixel((x+1, y), value) 296 297 return imp 298 299 def convert_image(im): 300 301 "Convert image 'im' to an appropriate output representation." 302 303 width, height = im.size 304 305 for y in range(0, height): 306 c = get_colours(im, y) 307 308 for l in get_combinations(c, 4): 309 most = [] 310 for f, value in l: 311 most.append(value) 312 for x in range(0, width): 313 rgb = im.getpixel((x, y)) 314 try: 315 value = get_value(rgb, most, True) 316 except ValueError: 317 break # try next combination 318 else: 319 break # use this combination 320 else: 321 most = [] 322 for f, value in c[:4]: # use the first four 323 most.append(value) 324 325 for x in range(0, width): 326 rgb = im.getpixel((x, y)) 327 value = get_value(rgb, most) 328 im.putpixel((x, y), value) 329 330 if x < width - 1: 331 rgbn = im.getpixel((x+1, y)) 332 rgbn = ( 333 clip(rgbn[0] + (rgb[0] - value[0]) / 4.0), 334 clip(rgbn[1] + (rgb[1] - value[1]) / 4.0), 335 clip(rgbn[2] + (rgb[2] - value[2]) / 4.0) 336 ) 337 im.putpixel((x+1, y), rgbn) 338 339 if y < height - 1: 340 rgbn = im.getpixel((x, y+1)) 341 rgbn = ( 342 clip(rgbn[0] + (rgb[0] - value[0]) / 2.0), 343 clip(rgbn[1] + (rgb[1] - value[1]) / 2.0), 344 clip(rgbn[2] + (rgb[2] - value[2]) / 2.0) 345 ) 346 im.putpixel((x, y+1), rgbn) 347 348 class SimpleImage: 349 350 "An image behaving like PIL.Image." 351 352 def __init__(self, data, size): 353 self.data = data 354 self.width, self.height = self.size = size 355 356 def copy(self): 357 return SimpleImage(self.data[:], self.size) 358 359 def getpixel(self, xy): 360 x, y = xy 361 return self.data[y * self.width + x] 362 363 def putpixel(self, xy, value): 364 x, y = xy 365 self.data[y * self.width + x] = value 366 367 def getdata(self): 368 return self.data 369 370 # Test program. 371 372 if __name__ == "__main__": 373 data = [(0, 0, 0)] * 1024 374 size = (32, 32) 375 376 im = SimpleImage(data, size) 377 378 process_image(im, 1.0, 0.0, 1.0, 0.0) 379 imp = preview_image(im, False) 380 convert_image(im) 381 382 test_im = SimpleImage(data, size) 383 test_slice(test_im, 32, 0) 384 385 test_flat_im = SimpleImage(data, size) 386 test_flat_slice(test_flat_im, 32, (200, 100, 50)) 387 388 # vim: tabstop=4 expandtab shiftwidth=4