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, return None if the colour cannot 145 be expressed using any of the chosen colours. 146 """ 147 148 l = pattern(rgb, chosen) 149 limit = sum([f for f, c in l]) 150 if not limit: 151 if fail: 152 return None 153 else: 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 r, g, b = rgb 171 return saturate_value(r, exp), saturate_value(g, exp), saturate_value(b, exp) 172 173 def saturate_value(x, exp): 174 return int(127.5 + sign(x - 127.5) * 127.5 * pow(abs(x - 127.5) / 127.5, exp)) 175 176 def amplify_rgb(rgb, exp): 177 r, g, b = rgb 178 return amplify_value(r, exp), amplify_value(g, exp), amplify_value(b, exp) 179 180 def amplify_value(x, exp): 181 return int(pow(x / 255.0, exp) * 255.0) 182 183 # Image operations. 184 185 def get_colours(im, y): 186 187 "Get a colour distribution from image 'im' for the row 'y'." 188 189 width, height = im.size 190 c = {} 191 for x in range(0, width): 192 rgb = im.getpixel((x, y)) 193 194 # Sum the colour probabilities. 195 196 for f, value in combination(rgb): 197 if not c.has_key(value): 198 c[value] = f 199 else: 200 c[value] += f 201 202 d = [] 203 for value, n in c.items(): 204 d.append((n/width, value)) 205 d.sort(reverse=True) 206 return d 207 208 def get_combinations(c, n): 209 210 """ 211 Get combinations of colours from 'c' of size 'n' in decreasing order of 212 probability. 213 """ 214 215 all = [] 216 for l in itertools.combinations(c, n): 217 total = 0 218 for f, value in l: 219 total += f 220 all.append((total, l)) 221 all.sort(reverse=True) 222 cc = [] 223 for total, l in all: 224 cc.append(l) 225 return cc 226 227 def test_slice(im, size, r): 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 233 def test_flat_slice(im, size, rgb): 234 for y in range(0, size): 235 for x in range(0, size): 236 im.putpixel((x, y), get_value(rgb)) 237 238 def count_colours(im, colours): 239 240 """ 241 Count colours on each row of image 'im', returning a tuple indicating the 242 first row with more than the given number of 'colours' together with the 243 found colours; otherwise returning None. 244 """ 245 246 width, height = im.size 247 248 for y in range(0, height): 249 l = set(im.getdata()[y * width:(y+1) * width]) 250 if len(l) > colours: 251 return (y, l) 252 return None 253 254 def process_image(im, saturate, desaturate, darken, brighten): 255 256 """ 257 Process image 'im' using the given options: 'saturate', 'desaturate', 258 'darken', 'brighten'. 259 """ 260 261 width, height = im.size 262 263 if saturate or desaturate or darken or brighten: 264 for y in range(0, height): 265 for x in range(0, width): 266 rgb = im.getpixel((x, y)) 267 if saturate or desaturate: 268 rgb = saturate_rgb(rgb, saturate and 0.5 / saturate or 2 * desaturate) 269 if darken or brighten: 270 rgb = amplify_rgb(rgb, brighten and 0.5 / brighten or 2 * darken) 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 = [value for f, value in l] 303 for x in range(0, width): 304 rgb = im.getpixel((x, y)) 305 value = get_value(rgb, most, True) 306 if value is None: 307 break # try next combination 308 else: 309 break # use this combination 310 else: 311 most = [value for f, value in c[:4]] # use the first four 312 313 for x in range(0, width): 314 rgb = im.getpixel((x, y)) 315 value = get_value(rgb, most) 316 im.putpixel((x, y), value) 317 318 if x < width - 1: 319 rgbn = im.getpixel((x+1, y)) 320 rgbn = ( 321 clip(rgbn[0] + (rgb[0] - value[0]) / 4.0), 322 clip(rgbn[1] + (rgb[1] - value[1]) / 4.0), 323 clip(rgbn[2] + (rgb[2] - value[2]) / 4.0) 324 ) 325 im.putpixel((x+1, y), rgbn) 326 327 if y < height - 1: 328 rgbn = im.getpixel((x, y+1)) 329 rgbn = ( 330 clip(rgbn[0] + (rgb[0] - value[0]) / 2.0), 331 clip(rgbn[1] + (rgb[1] - value[1]) / 2.0), 332 clip(rgbn[2] + (rgb[2] - value[2]) / 2.0) 333 ) 334 im.putpixel((x, y+1), rgbn) 335 336 class SimpleImage: 337 338 "An image behaving like PIL.Image." 339 340 def __init__(self, data, size): 341 self.data = data 342 self.width, self.height = self.size = size 343 344 def copy(self): 345 return SimpleImage(self.data[:], self.size) 346 347 def getpixel(self, xy): 348 x, y = xy 349 return self.data[y * self.width + x] 350 351 def putpixel(self, xy, value): 352 x, y = xy 353 self.data[y * self.width + x] = value 354 355 def getdata(self): 356 return self.data 357 358 # Test program. 359 360 if __name__ == "__main__": 361 data = [(0, 0, 0)] * 1024 362 size = (32, 32) 363 364 im = SimpleImage(data, size) 365 366 process_image(im, 1.0, 0.0, 1.0, 0.0) 367 imp = preview_image(im, False) 368 convert_image(im) 369 370 test_im = SimpleImage(data, size) 371 test_slice(test_im, 32, 0) 372 373 test_flat_im = SimpleImage(data, size) 374 test_flat_slice(test_flat_im, 32, (200, 100, 50)) 375 376 # vim: tabstop=4 expandtab shiftwidth=4