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 optimiserlib import * 24 from os.path import split, splitext 25 import EXIF 26 import PIL.Image 27 import sys 28 29 # Image operations. 30 31 def get_colours(im, y): 32 33 "Get a colour distribution from image 'im' for the row 'y'." 34 35 width, height = im.size 36 c = {} 37 for x in range(0, width): 38 rgb = im.getpixel((x, y)) 39 40 # Sum the colour probabilities. 41 42 for f, value in combination(rgb): 43 if not c.has_key(value): 44 c[value] = f 45 else: 46 c[value] += f 47 48 d = [(n/width, value) for value, n in c.items()] 49 d.sort(reverse=True) 50 return d 51 52 def test(): 53 54 "Generate slices of the colour cube." 55 56 size = 512 57 for r in (0, 63, 127, 191, 255): 58 im = PIL.Image.new("RGB", (size, size)) 59 for g in range(0, size): 60 for b in range(0, size): 61 value = get_value((r, (g * 256) / size, (b * 256 / size))) 62 im.putpixel((g, b), value) 63 im.save("rgb%d.png" % r) 64 65 def test_flat(rgb): 66 67 "Generate a flat image for the colour 'rgb'." 68 69 size = 64 70 im = PIL.Image.new("RGB", (size, size)) 71 for y in range(0, size): 72 for x in range(0, size): 73 im.putpixel((x, y), get_value(rgb)) 74 im.save("rgb%02d%02d%02d.png" % rgb) 75 76 def rotate_and_scale(exif, im, width, height, rotate): 77 78 """ 79 Using the given 'exif' information, rotate and scale image 'im' given the 80 indicated 'width' and 'height' constraints and any explicit 'rotate' 81 indication. The returned image will be within the given 'width' and 82 'height', filling either or both, and preserve its original aspect ratio. 83 """ 84 85 if rotate or exif and exif["Image Orientation"].values == [6L]: 86 im = im.rotate(270) 87 88 w, h = im.size 89 if w > h: 90 height = (width * h) / w 91 else: 92 width = (height * w) / h 93 94 return im.resize((width, height)) 95 96 def count_colours(im, colours): 97 98 """ 99 Count colours on each row of image 'im', returning a tuple indicating the 100 first row with more than the given number of 'colours' together with the 101 found colours; otherwise returning None. 102 """ 103 104 width, height = im.size 105 106 for y in range(0, height): 107 l = set() 108 for x in range(0, width): 109 l.add(im.getpixel((x, y))) 110 if len(l) > colours: 111 return (y, l) 112 return None 113 114 def process_image(pim, saturate, desaturate, darken, brighten): 115 116 """ 117 Process image 'pim' using the given options: 'saturate', 'desaturate', 118 'darken', 'brighten'. 119 """ 120 121 width, height = pim.size 122 im = SimpleImage(list(pim.getdata()), pim.size) 123 124 if saturate or desaturate or darken or brighten: 125 for y in range(0, height): 126 for x in range(0, width): 127 rgb = im.getpixel((x, y)) 128 if saturate or desaturate: 129 rgb = saturate_rgb(rgb, saturate and 0.5 / saturate or 2 * desaturate) 130 if darken or brighten: 131 rgb = amplify_rgb(rgb, brighten and 0.5 / brighten or 2 * darken) 132 im.putpixel((x, y), rgb) 133 134 pim.putdata(im.getdata()) 135 136 def preview_image(pim, half_resolution_preview=False): 137 138 "Return a preview copy of image 'pim'." 139 140 width, height = pim.size 141 imp = pim.copy() 142 im = SimpleImage(list(pim.getdata()), pim.size) 143 step = half_resolution_preview and 2 or 1 144 145 for y in range(0, height): 146 for x in range(0, width): 147 rgb = im.getpixel((x, y)) 148 value = get_value(rgb) 149 im.putpixel((x, y), value) 150 if half_resolution_preview: 151 im.putpixel((x+1, y), value) 152 153 imp.putdata(im.getdata()) 154 return imp 155 156 def convert_image(pim): 157 158 "Convert image 'pim' to an appropriate output representation." 159 160 width, height = pim.size 161 im = SimpleImage(list(pim.getdata()), pim.size) 162 163 for y in range(0, height): 164 c = get_colours(im, y) 165 166 for l in get_combinations(c, 4): 167 most = [value for f, value in l] 168 for x in range(0, width): 169 rgb = im.getpixel((x, y)) 170 value = get_value(rgb, most, True) 171 if value is None: 172 break # try next combination 173 else: 174 break # use this combination 175 else: 176 most = [value for f, value in c[:4]] # use the first four 177 178 for x in range(0, width): 179 rgb = im.getpixel((x, y)) 180 value = get_value(rgb, most) 181 im.putpixel((x, y), value) 182 183 if x < width - 1: 184 rgbn = im.getpixel((x+1, y)) 185 rgbn = ( 186 clip(rgbn[0] + (rgb[0] - value[0]) / 4.0), 187 clip(rgbn[1] + (rgb[1] - value[1]) / 4.0), 188 clip(rgbn[2] + (rgb[2] - value[2]) / 4.0) 189 ) 190 im.putpixel((x+1, y), rgbn) 191 192 if y < height - 1: 193 rgbn = im.getpixel((x, y+1)) 194 rgbn = ( 195 clip(rgbn[0] + (rgb[0] - value[0]) / 2.0), 196 clip(rgbn[1] + (rgb[1] - value[1]) / 2.0), 197 clip(rgbn[2] + (rgb[2] - value[2]) / 2.0) 198 ) 199 im.putpixel((x, y+1), rgbn) 200 201 pim.putdata(im.getdata()) 202 203 def get_float(options, flag): 204 try: 205 i = options.index(flag) 206 if i+1 < len(options) and options[i+1].isdigit(): 207 return float(options[i+1]) 208 else: 209 return 1.0 210 except ValueError: 211 return 0.0 212 213 class SimpleImage: 214 215 "An image behaving like PIL.Image." 216 217 def __init__(self, data, size): 218 self.data = data 219 self.width, self.height = self.size = size 220 221 def copy(self): 222 return SimpleImage(self.data[:], self.size) 223 224 def getpixel(self, xy): 225 x, y = xy 226 return self.data[y * self.width + x] 227 228 def putpixel(self, xy, value): 229 x, y = xy 230 self.data[y * self.width + x] = value 231 232 def getdata(self): 233 return self.data 234 235 # Main program. 236 237 if __name__ == "__main__": 238 239 # Test options. 240 241 if "--test" in sys.argv: 242 test() 243 sys.exit(0) 244 elif "--test-flat" in sys.argv: 245 test_flat((120, 40, 60)) 246 sys.exit(0) 247 elif "--help" in sys.argv: 248 print >>sys.stderr, """\ 249 Usage: %s <input filename> <output filename> [ <options> ] 250 251 Options are... 252 253 -s - Saturate the input image (can be followed by a float, default 1.0) 254 -d - Desaturate the input image (can be followed by a float, default 1.0) 255 -D - Darken the input image (can be followed by a float, default 1.0) 256 -B - Brighten the input image (can be followed by a float, default 1.0) 257 258 -r - Rotate the input image clockwise 259 -p - Generate a separate preview image 260 -h - Make the preview image with half horizontal resolution (MODE 2) 261 -v - Verify the output image (loaded if -n is given) 262 -n - Generate no output image 263 """ % split(sys.argv[0])[1] 264 sys.exit(1) 265 266 width = 320 267 height = 256 268 269 input_filename, output_filename = sys.argv[1:3] 270 basename, ext = splitext(output_filename) 271 preview_filename = "".join([basename + "_preview", ext]) 272 273 options = sys.argv[3:] 274 275 # Preprocessing options that can be repeated for extra effect. 276 277 saturate = get_float(options, "-s") 278 desaturate = get_float(options, "-d") 279 darken = get_float(options, "-D") 280 brighten = get_float(options, "-B") 281 282 # General output options. 283 284 rotate = "-r" in options 285 preview = "-p" in options 286 half_resolution_preview = "-h" in options 287 verify = "-v" in options 288 no_normal_output = "-n" in options 289 make_image = not no_normal_output 290 291 # Load the input image if requested. 292 293 if make_image or preview: 294 exif = EXIF.process_file(open(input_filename)) 295 im = PIL.Image.open(input_filename).convert("RGB") 296 im = rotate_and_scale(exif, im, width, height, rotate) 297 298 process_image(im, saturate, desaturate, darken, brighten) 299 300 # Generate a preview if requested. 301 302 if preview: 303 preview_image(im, half_resolution_preview).save(preview_filename) 304 305 # Generate an output image if requested. 306 307 if make_image: 308 convert_image(im) 309 im.save(output_filename) 310 311 # Verify the output image (which may be loaded) if requested. 312 313 if verify: 314 if no_normal_output: 315 im = PIL.Image.open(output_filename).convert("RGB") 316 317 result = count_colours(im, 4) 318 if result is not None: 319 y, colours = result 320 print "Image %s: row %d has the following colours: %s" % (output_filename, y, "; ".join([repr(c) for c in colours])) 321 322 # vim: tabstop=4 expandtab shiftwidth=4