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.0, 255.0)) 40 41 def restore(srgb): 42 return int(srgb[0] * 255.0), int(srgb[1] * 255.0), int(srgb[2] * 255.0) 43 44 def scale(rgb): 45 return float(rgb[0]) / 255.0, float(rgb[1]) / 255.0, float(rgb[2]) / 255.0 46 47 def invert(srgb): 48 return 1.0 - srgb[0], 1.0 - srgb[1], 1.0 - srgb[2] 49 50 # Colour distribution functions. 51 52 def combination(rgb): 53 54 "Return the colour distribution for 'rgb'." 55 56 # Get the colour with components scaled from 0 to 1, plus the inverted 57 # component values. 58 59 srgb = scale(rgb) 60 rgbi = invert(srgb) 61 rc = (rgbi[0], srgb[0]) 62 gc = (rgbi[1], srgb[1]) 63 bc = (rgbi[2], srgb[2]) 64 65 # For each corner of the colour cube (primary and secondary colours plus 66 # black and white), calculate the corner value's contribution to the 67 # input colour. 68 69 d = [] 70 for corner in corners: 71 crgb = scale(corner) 72 rs, gs, bs = crgb 73 ri, gi, bi = int(rs), int(gs), int(bs) 74 75 # Obtain inverted channel values where corner channels are low; 76 # obtain original channel values where corner channels are high. 77 78 f = rc[ri] * gc[gi] * bc[bi] 79 d.append((f, corner)) 80 81 # Balance the corner contributions. 82 83 return balance(d) 84 85 def complements(rgb): 86 87 "Return 'rgb' and its complement." 88 89 return rgb, restore(invert(scale(rgb))) 90 91 def balance(d): 92 93 """ 94 Balance distribution 'd', cancelling opposing values and their complements 95 and replacing their common contributions with black and white contributions. 96 """ 97 98 dd = {} 99 for f, value in d: 100 dd[value] = f 101 for colour in [(0, 0, 0), (255, 0, 0), (0, 255, 0), (0, 0, 255)]: 102 primary, secondary = complements(colour) 103 common = min(dd[primary], dd[secondary]) 104 dd[primary] -= common 105 dd[secondary] -= common 106 d = [] 107 for value, f in dd.items(): 108 d.append((f, value)) 109 return d 110 111 def combine(d): 112 113 "Combine distribution 'd' to get a colour value." 114 115 out = [0, 0, 0] 116 for v, rgb in d: 117 out[0] += v * rgb[0] 118 out[1] += v * rgb[1] 119 out[2] += v * rgb[2] 120 return int(out[0]), int(out[1]), int(out[2]) 121 122 def pattern(rgb, chosen=None): 123 124 """ 125 Obtain a sorted colour distribution for 'rgb', optionally limited to any 126 specified 'chosen' colours. 127 """ 128 129 l = [] 130 for f, value in combination(rgb): 131 if not chosen or value in chosen: 132 l.append((f, value)) 133 l.sort(reverse=True) 134 return l 135 136 def get_value(rgb, chosen=None, fail=False): 137 138 """ 139 Get an output colour for 'rgb', optionally limited to any specified 'chosen' 140 colours. If 'fail' is set to a true value, raise ValueError if the colour 141 cannot be expressed using any of the chosen colours. 142 """ 143 144 l = pattern(rgb, chosen) 145 limit = 0 146 for f, c in l: 147 limit += f 148 149 if not limit: 150 if fail: 151 raise ValueError 152 else: 153 return l[randrange(0, len(l))][1] 154 155 choose = random() * limit 156 threshold = 0 157 for f, c in l: 158 threshold += f 159 if choose < threshold: 160 return c 161 return c 162 163 # Colour processing operations. 164 165 def sign(x): 166 return x >= 0 and 1 or -1 167 168 def saturate_rgb(rgb, exp): 169 return saturate_value(rgb[0], exp), saturate_value(rgb[1], exp), saturate_value(rgb[2], exp) 170 171 def saturate_value(x, exp): 172 return int(127.5 + sign(x - 127.5) * 127.5 * pow(abs(x - 127.5) / 127.5, exp)) 173 174 def amplify_rgb(rgb, exp): 175 return amplify_value(rgb[0], exp), amplify_value(rgb[1], exp), amplify_value(rgb[2], exp) 176 177 def amplify_value(x, exp): 178 return int(pow(x / 255.0, exp) * 255.0) 179 180 # Image operations. 181 182 def get_colours(im, y): 183 184 "Get a colour distribution from image 'im' for the row 'y'." 185 186 width, height = im.size 187 c = {} 188 for x in range(0, width): 189 rgb = im.getpixel((x, y)) 190 191 # Sum the colour probabilities. 192 193 for f, value in combination(rgb): 194 if not c.has_key(value): 195 c[value] = f 196 else: 197 c[value] += f 198 199 d = [] 200 for value, n in c.items(): 201 d.append((n/width, value)) 202 d.sort(reverse=True) 203 return d 204 205 def get_combinations(c, n): 206 207 """ 208 Get combinations of colours from 'c' of size 'n' in decreasing order of 209 probability. 210 """ 211 212 all = [] 213 for l in itertools.combinations(c, n): 214 total = 0 215 for f, value in l: 216 total += f 217 all.append((total, l)) 218 all.sort(reverse=True) 219 cc = [] 220 for total, l in all: 221 cc.append(l) 222 return cc 223 224 def test_slice(im, size, r): 225 for g in range(0, size): 226 for b in range(0, size): 227 value = get_value((r, (g * 256) / size, (b * 256 / size))) 228 im.putpixel((g, b), value) 229 230 def test_flat_slice(im, size, rgb): 231 for y in range(0, size): 232 for x in range(0, size): 233 im.putpixel((x, y), get_value(rgb)) 234 235 def count_colours(im, colours): 236 237 """ 238 Count colours on each row of image 'im', returning a tuple indicating the 239 first row with more than the given number of 'colours' together with the 240 found colours; otherwise returning None. 241 """ 242 243 width, height = im.size 244 245 for y in range(0, height): 246 l = set(im.getdata()[y * width:(y+1) * width]) 247 if len(l) > colours: 248 raise ValueError(y, l) 249 250 def process_image(im, saturate, desaturate, darken, brighten): 251 252 """ 253 Process image 'im' using the given options: 'saturate', 'desaturate', 254 'darken', 'brighten'. 255 """ 256 257 width, height = im.size 258 259 if saturate or desaturate or darken or brighten: 260 for y in range(0, height): 261 for x in range(0, width): 262 rgb = im.getpixel((x, y)) 263 if saturate: 264 rgb = saturate_rgb(rgb, 0.5 / saturate) 265 elif desaturate: 266 rgb = saturate_rgb(rgb, 2 * desaturate) 267 if darken: 268 rgb = amplify_rgb(rgb, 2 * darken) 269 elif brighten: 270 rgb = amplify_rgb(rgb, 0.5 / brighten) 271 im.putpixel((x, y), rgb) 272 273 def preview_image(im, half_resolution_preview=False): 274 275 "Return a preview copy of image 'im'." 276 277 width, height = im.size 278 279 imp = im.copy() 280 step = half_resolution_preview and 2 or 1 281 282 for y in range(0, height): 283 for x in range(0, width, step): 284 rgb = imp.getpixel((x, y)) 285 value = get_value(rgb) 286 imp.putpixel((x, y), value) 287 if half_resolution_preview: 288 imp.putpixel((x+1, y), value) 289 290 return imp 291 292 def convert_image(im): 293 294 "Convert image 'im' to an appropriate output representation." 295 296 width, height = im.size 297 298 for y in range(0, height): 299 c = get_colours(im, y) 300 301 for l in get_combinations(c, 4): 302 most = [] 303 for f, value in l: 304 most.append(value) 305 for x in range(0, width): 306 rgb = im.getpixel((x, y)) 307 try: 308 value = get_value(rgb, most, True) 309 except ValueError: 310 break # try next combination 311 else: 312 break # use this combination 313 else: 314 most = [] 315 for f, value in c[:4]: # use the first four 316 most.append(value) 317 318 for x in range(0, width): 319 rgb = im.getpixel((x, y)) 320 value = get_value(rgb, most) 321 im.putpixel((x, y), value) 322 323 if x < width - 1: 324 rgbn = im.getpixel((x+1, y)) 325 im.putpixel((x+1, y), ( 326 clip(rgbn[0] + (rgb[0] - value[0]) / 4.0), 327 clip(rgbn[1] + (rgb[1] - value[1]) / 4.0), 328 clip(rgbn[2] + (rgb[2] - value[2]) / 4.0) 329 )) 330 331 if y < height - 1: 332 rgbn = im.getpixel((x, y+1)) 333 im.putpixel((x, y+1), ( 334 clip(rgbn[0] + (rgb[0] - value[0]) / 2.0), 335 clip(rgbn[1] + (rgb[1] - value[1]) / 2.0), 336 clip(rgbn[2] + (rgb[2] - value[2]) / 2.0) 337 )) 338 339 class SimpleImage: 340 341 "An image behaving like PIL.Image." 342 343 def __init__(self, data, size): 344 self.data = data 345 self.width, self.height = self.size = size 346 347 def copy(self): 348 return SimpleImage(self.data[:], self.size) 349 350 def getpixel(self, xy): 351 x, y = xy 352 return self.data[y * self.width + x] 353 354 def putpixel(self, xy, value): 355 x, y = xy 356 self.data[y * self.width + x] = value 357 358 def getdata(self): 359 return self.data 360 361 # Test program. 362 363 if __name__ == "__main__": 364 data = [(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0)] 365 size = (2, 2) 366 367 im = SimpleImage(data, size) 368 369 process_image(im, 1.0, 0.0, 1.0, 0.0) 370 imp = preview_image(im, False) 371 convert_image(im) 372 count_colours(im, 4) 373 im.getdata() 374 375 test_im = SimpleImage(data, size) 376 test_slice(test_im, 32, 0) 377 378 test_flat_im = SimpleImage(data, size) 379 test_flat_slice(test_flat_im, 32, (200, 100, 50)) 380 381 rgb = (200, 150, 100) 382 combine(pattern(rgb)) == rgb 383 384 # vim: tabstop=4 expandtab shiftwidth=4