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