-
Notifications
You must be signed in to change notification settings - Fork 1
/
Cimpl.py
579 lines (452 loc) · 19.6 KB
/
Cimpl.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
"""Cimpl (Carleton Image Manipulation Python Library).
2013-2017, D.L. Bailey,
Department of Systems and Computer Engineering,
Carleton University
Cimpl provides a collection of functions for manipulating digital images.
Programmers should use the procedural interface to Cimpl; i.e., call the
"global" Colour, Image functions and file dialogue functions.
To learn about these functions, use Python's help facility:
Image functions:
>>> help(load_image)
>>> help(create_image)
>>> help(copy)
>>> help(get_width)
>>> help(get_height)
>>> help(get_color)
>>> help(set_color)
>>> help(save_as)
>>> help(save)
>>> help(set_zoom)
>>> help(show)
Colour functions:
>>> help(create_color)
>>> help(distance)
File dialogue functions:
>>> help(choose_file)
>>> help(choose_save_filename)
Do not call the methods provided by the underlying Image and Color
classes. These classes may be modified or replaced in future releases of
this module, and backwards compatibility is not guaranteed. Specifically,
class names and method names may be renamed, and classes and methods may be
replaced or deleted.
"""
import os
import math
from tkinter import *
import tkinter.filedialog
import PIL.Image
import PIL.ImageTk
RELEASE = "Cimpl 1.04; October 6, 2017"
IMAGE_FILE_FORMATS = ['.bmp', '.gif', '.jpg', '.jpeg', '.png', '.tif', '.tiff']
#-----------------------------------------------------------------
def _adjust_component(comp):
"""Return comp as an integer between 0 and 255, inclusive, returning 0
if comp is negative and capping values >= 256 at 255.
"""
comp = int(comp)
return max(0, min(255, comp))
class Color(tuple):
"""An RGB color.
When an instance is created, the RGB component values are quietly adjusted,
as required, to ensure that they are ints in the range 0..255, inclusive.
Examples:
Color(120, 60, 200) yields the color (120, 60, 200)
Color(-120, 60, 280) yields the color (0, 60, 255)
Color(120.0, 60.5, 200.2) yields the color (120, 60, 200)
Because Color is a subclass of tuple, Color objects can be treated as
tuples. For example, to retrieve the rgb components stored in a Color
object, it can be subscripted (indexed):
col = Color(120, 60, 200)
...
r = col[0] # r is bound to 120
g = col[1] # g is bound to 60
b = col[2] # b is bound to 200
Or, we can unpack a Color object, the same way we can unpack a tuple:
r, g, b = col # r is bound to 120, g is bound to 60, b is bound to 200
To convert a Color object col to a tuple, do this:
tuple(col) # Returns the tuple (120, 60, 200)
"""
__slots__ = () # Binding __slots__ to an empty tuple prevents instance
# dictionaries from being created, reducing memory
# requirements
def __new__(_cls, red, green, blue):
"""Return a new instance of Color(red, green, blue)."""
return tuple.__new__(_cls, (_adjust_component(red),
_adjust_component(green),
_adjust_component(blue)))
@classmethod
def _make(cls, t):
# Make a new Color object from (r, g, b) tuple t.
# This method assumes that t is a 3-tuple of ints, each in the
# range 0 to 255, inclusive. THIS IS NOT CHECKED.
#
# The preferred way for application code to convert a tuple t to a
# Color object is:
#
# col = Color(t[0], [t1], t[2])
#
# or:
#
# r, g, b = t
# col = Color(r, g, b)
#
# This way ensures that the rgb components will be converted, if
# necessary, to integers in the range 0..255 when the Color object is
# initialized.
# Originally, I was going to have this function verify that it
# was passed a 3-tuple.
#
# if not isinstance(t, tuple):
# raise TypeError('Argument is not a tuple')
# if len(t) != 3:
# raise TypeError('Expected 3 values in tuple, got %d' % len(t))
return tuple.__new__(cls, t)
def __repr__(self):
"""Return the "official" string representation of the Color.
This string is a valid expression that will yield a Color object with
the same value when passed to eval().
"""
return 'Color(red={0[0]}, green={0[1]}, blue={0[2]})'.format(self)
class Image(object):
"""
A Image is a wrapper for an instance of PIL's Image class.
Supported image formats include: JPEG, GIF, TIFF, PNG and BMP.
To load an image from a file:
image = Image(a_filename)
To create a blank image with specified dimensions:
image = Image(width=width_in_pixels, height=height_in_pixels)
By default, the blank image's color is white. A different image color can be
specified with a Color object:
image = Image(width=width_in_pixels, height=height_in_pixels
color=Cimpl.Color(red, green, blue))
To duplicate an image:
original = Image(...)
duplicate = Image(image=original)
"""
def __init__(self, filename=None, image=None,
width=None, height=None, color=Color(255, 255, 255)):
if filename is not None: # load image from file
self.pil_image = PIL.Image.open(filename).convert("RGB")
self.filename = filename
elif image is not None: # copy an image
# To make a deep copy of the Image we're duplicating, we need to
# copy its PIL Image.
self.pil_image = image.pil_image.copy()
self.filename = None
elif width is None and height is None and color is None:
raise TypeError('Image(): called with no arguments?')
elif width is None or height is None:
raise TypeError('Image(): missing width or height argument')
elif width > 0 and height > 0: # create a blank image
self.pil_image = PIL.Image.new(mode="RGB", size=(width, height),
color=tuple(color))
self.filename = None
else:
raise ValueError('Image(): width and height must be > 0')
self.zoomfactor = 1 # By default, display images at their
# original size.
self.pixels = self.pil_image.load() # The pixel access object for the
# PIL Image; essentially a 2-D array
# of (r, g, b) tuples.
def copy(self):
"""Return a deep copy of this Image.
"""
dup = Image(image=self)
return dup
def set_zoom(self, factor):
"""Specify the amount that the image should be expanded when it is
displayed; e.g., if factor is 3 the image is displayed at
3 times its original size.
"""
if isinstance(factor, int) and factor > 0:
self.zoomfactor = factor
else:
raise ValueError("factor must be a positive integer")
def get_width(self):
"""Return the width of this Image, in pixels.
"""
return self.pil_image.size[0]
def get_height(self):
"""Return the height of this Image, in pixels.
"""
return self.pil_image.size[1]
def get_filename(self):
"""Return the name of the file where this Image is stored.
"""
return self.filename
def __iter__(self):
"""Return a generator object that iterates over this Image's pixels
from left to right, top to bottom. The values when iterating are
Color objects, each containing the RGB color of one pixel.
"""
width = self.get_width()
height = self.get_height()
for y in range(0, height):
for x in range(0, width):
col = Color._make(self.pixels[x, y])
yield x, y, col
def get_color(self, x, y):
"""Return a Color containing the RGB components of the pixel at
location (x, y) in this Image.
"""
return Color._make(self.pixels[x, y])
def set_color(self, x, y, color):
"""Set the color of the pixel at location (x, y) in this Image,
to the RGB values stored in Color object, color.
"""
# Ensure that color is bound to a Color object before we update the
# pixel access object. Calling isinstance is frowned upon in some
# circles, but we need to ensure that color is not bound to an
# arbitrary sequence containing values outside the range 0..255.
if not isinstance(color, Color):
raise TypeError('Parameter color is not a Color object')
#self.pixels[x, y] = (color[0], color[1], color[2])
self.pixels[x, y] = tuple(color)
def write_to(self, filename):
"""Save this Image to filename, overwriting the existing file.
Raise a ValueError if
- filename is None;
- if filename has no extension.
- if the filename's extension doesn't specify an image file format
supported by this module.
FIXME: reset the image's filename.
"""
if filename:
ext = os.path.splitext(filename)[-1]
if ext == '':
raise ValueError('Filename has no extension')
# Extensions must be entirely lower-case or upper-case, but not
# mixed case.
if ext in IMAGE_FILE_FORMATS or \
(ext.isupper() and ext.lower() in IMAGE_FILE_FORMATS):
self.pil_image.save(filename)
#self.set_filename_and_title(filename)
else:
raise ValueError("%s is not a supported image file format." \
% ext)
else:
raise ValueError("Parameter filename is None.")
def _zoom_image(self):
"""Return a copy of this Image, expanding it by the image's
zoom factor (see set_zoom).
"""
copy = Image(width=self.get_width() * self.zoomfactor,
height=self.get_height() * self.zoomfactor,
color=Color(255, 255, 255))
for x, y, col in self:
scaled_x = x * self.zoomfactor
scaled_y = y * self.zoomfactor
for j in range(self.zoomfactor):
for i in range(self.zoomfactor):
copy.set_color(scaled_x + i, scaled_y + j, col)
return copy
def show(self):
root = Tk()
# By default, display this image's PIL image access object.
pil_image = self.pil_image
if self.zoomfactor != 1:
# Make an enlarged copy of this image and display its
# PIL image access object.
pil_image = self._zoom_image().pil_image
if self.filename is None:
view = ImageViewer(root, pil_image)
else:
# Use the name of the image file, without the drive/directory part
# of its pathname, as the window's title.
title = os.path.basename(self.filename)
view = ImageViewer(root, pil_image, title)
root.mainloop()
#---------------------------------------------------
# ImageViewer
class ImageViewer(object):
def __init__(self, master, pil_image, title = "New Image"):
"""Initialize an image viewer (a Tk window) with parent widget master.
pil_image is bound to the instance of PIL.Image.Image that contains
the image to be displayed.
"""
master.title(title)
image_width = pil_image.size[0]
image_height = pil_image.size[1]
# Build a canvas big enough to display the image
self.canvas = Canvas(master,
width=image_width,
height=image_height)
self.photo_image = PIL.ImageTk.PhotoImage(pil_image)
# The PhotoImage object must be bound to an instance variable
# (which exists for the lifetime of the ImageViewer object) instead
# of a local variable. If we don't do this, the PhotoImage object
# might be garbage collected after __init__ returns, but before
# we run the Tk/Tcl event loop, and the image won't appear in the
# canvas. This is a bug in PIL...
# Place the image in the canvas.
self.canvas.create_image(image_width // 2,
image_height // 2,
image = self.photo_image)
self.canvas.pack()
master.resizable(0, 0) # Don't allow the window to be resized
#---------------------------------------------------
# "Global" Colour functions
def create_color(red, green, blue):
"""(int, int, int) -> Cimpl.Color
Return a Color object with the RGB components specified by red, green
and blue.
When the Color object is created, non-integer component values are
converted, if possible, to ints; negative values are converted to 0,
and values > 255 are capped at 255.
"""
return Color(red, green, blue)
def distance(color1, color2):
"""(Cimpl.Color, Cimpl.Color) -> float
Return the Euclidean distance between two Color objects.
"""
r1, g1, b1 = color1
r2, g2, b2 = color2
return math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2)
#---------------------------------------------------------------------------
# "Global" Image functions
def load_image(filename):
""" (str) -> Cimpl.Image
Return an Image loaded from filename.
"""
return Image(filename)
def create_image(width, height, color=Color(255, 255, 255)):
""" (int, int) -> Cimpl.Image
(int, int, Cimpl.Color) -> Cimpl.Image
Return a blank Image with the specified width and height, in pixels.
Parameter color is the colour of the Image. This parameter is optional;
if it is not provided when the function is called, the image's
colour is white.
"""
return Image(width=width, height=height, color=color)
def copy(pict):
""" (Cimpl.Image) -> Cimpl.Image
Return a deep copy of Image pict.
"""
return pict.copy()
def get_width(pict):
""" (Cimpl.Image) -> int
Return the width of Image pict, in pixels.
"""
return pict.get_width()
def get_height(pict):
"""(Cimpl.Image) -> int
Return the height of Image pict, in pixels.
"""
return pict.get_height()
def get_color(pict, x, y):
""" (Cimpl.Image, int, int) -> Cimpl.Color
Return a Color containing the RGB components of the pixel at
location (x, y) in Image pict.
"""
return pict.get_color(x, y)
def set_color(pict, x, y, color):
""" (Cimpl.Image, int, int, Cimpl.Color) -> None
Set the color of the pixel at location (x, y) in Image pict,
to the RGB values stored in Color object, color.
"""
pict.set_color(x, y, color)
def save_as(pict, filename=None):
""" (Cimpl.Image) -> None
(Cimpl.Image, str) -> None
Save Image pict to the specified file. If no filename is supplied,
first prompt the user to interactively choose a directory and
filename.
Examples:
save_as(pict, 'mypicture.jpg') saves pict to mypicture.jpg
save_as(pict) asks the user to choose the directory and filename
"""
if not filename:
# The suggested name for the file is the image's current filename,
# if it has one; otherwise, use 'untitled'.
if pict.get_filename():
base = os.path.basename(pict.get_filename())
initial = os.path.splitext(base)[0]
else:
initial = 'untitled'
filename = choose_save_filename(initial)
if filename:
pict.write_to(filename)
def save(pict):
""" (Cimpl.Image) -> None
Save Image pict to its file, overwriting the existing file.
If this Image doesn't have a corresponding filename; i.e., this
instance has not yet been written to a file, the user will be prompted
to provide a filename. See save_as(pict, filename).
"""
name = pict.get_filename()
if name:
pict.write_to(name)
else:
save_as(pict)
def set_zoom(pict, factor):
""" (Cimpl.Image, int) -> None
Specify the amount that Image pict should be expanded when it is
displayed by show(); e.g., if factor is 3 the image is displayed at
3 times its original size.
"""
pict.set_zoom(factor)
def show(pict):
""" (Cimpl.Image) -> None
Display Image pict in a window. The user must close the window to
return control to the caller.
"""
pict.show()
#---------------------------------
# "Global" File Dialogues
IMAGE_FILE_TYPES = [('All files', '.*'),
('BMP', '.bmp'),
('GIF', '.gif'),
('PNG', '.png'),
('TIFF', '.tif'),
('TIFF', '.tiff'),
('JPEG', '.jpg'),
('JPEG', '.jpeg')]
def choose_save_filename(initial=''):
""" (None) -> str
(str) -> str
Display a Save As dialogue box. Return the complete path to
the new file.
Parameter initial is the string that is displayed in the dialogue
box's File name field. This parameter is optional; if it is not provided
when the function is called, the File name field is empty.
"""
root = Tk()
# Hide the top-level window. (We only want the Save As dialogue box
# to appear.)
root.withdraw()
path = tkinter.filedialog.asksaveasfilename(filetypes=IMAGE_FILE_TYPES,
initialfile=initial,
defaultextension='.jpg')
# Things I've discovered about the dialogue box displayed by
# asksaveasfilename():
#
# If the name we type in the "File name" field has no extension,
# the extension corresponding to the selected "Save as type" is appended
# to the name returned by the function.
# An exception to this occurs when "All files" is selected as the
# "Save as type" and we type a name without an extension. In this case,
# defaultextension is appended to the name.
# We can also type a name with an extension. If the extension is listed
# in the IMAGE_FILE_TYPES list, that name is returned as typed, with no
# changes to the extension; in other words, the extension implied by the
# selected "Save as type" isn't used.
# All bets are off if we type a name with an extension that isn't listed
# in the IMAGE_FILE_TYPES list. Sometimes an additional extension
# (corresponding to the selected "Save as type") is appended, but sometimes
# this doesn't happen. I haven't found an explanation for this behaviour
# in any of the online documentation or examples for Tkinter.
root.destroy() # Do we need to do this?
return path
def choose_file():
""" (None) -> str
Display an Open dialog box. Return the complete path to the
selected file.
"""
root = Tk()
# Hide the top-level window. (We only want the Open dialogue box
# to appear.)
root.withdraw()
path = tkinter.filedialog.askopenfilename(filetypes=IMAGE_FILE_TYPES)
root.destroy() # Do we need to do this?
return path
print(RELEASE)