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