Total Complexity | 45 |
Total Lines | 281 |
Duplicated Lines | 95.73 % |
Changes | 0 |
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like PillowImage.pillow often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
1 | import os |
||
2 | from tempfile import NamedTemporaryFile, TemporaryDirectory |
||
3 | from pathlib import Path |
||
4 | |||
5 | from PIL import Image, ImageDraw, ImageFont |
||
6 | from PyBundle import bundle_dir, resource_path |
||
7 | |||
8 | from PillowImage.font import FONT |
||
9 | from PillowImage.utils import img_adjust |
||
10 | |||
11 | |||
12 | View Code Duplication | class PillowImage: |
|
|
|||
13 | def __init__(self, img=None, size=(792, 612), mode='RGBA', color=(255, 255, 255, 0)): |
||
14 | """ |
||
15 | Construct an image composition using Pillow. |
||
16 | |||
17 | :param img: Image path |
||
18 | :param size: Size of new image (if img is None) |
||
19 | :param mode: Mode of the new image (if img is None) |
||
20 | :param color: Color of the new image (if img is None) |
||
21 | """ |
||
22 | if img: |
||
23 | # Open img and convert to RGBA color space |
||
24 | self.img = Image.open(img) |
||
25 | if self.img.mode != 'RGBA': |
||
26 | self.img = self.img.convert('RGBA') |
||
27 | else: |
||
28 | self.img = self.img.copy() |
||
29 | else: |
||
30 | # Create a black image |
||
31 | self.img = Image.new(mode, size, color=color) # 2200, 1700 for 200 DPI |
||
32 | self._tempdir = None |
||
33 | |||
34 | def __enter__(self): |
||
35 | return self |
||
36 | |||
37 | def __exit__(self, exc_type, exc_val, exc_tb): |
||
38 | self.cleanup() |
||
39 | |||
40 | @property |
||
41 | def tempdir(self): |
||
42 | if not self._tempdir: |
||
43 | self._tempdir = TemporaryDirectory(prefix='pillowimg_') |
||
44 | return self._tempdir |
||
45 | |||
46 | @property |
||
47 | def size(self): |
||
48 | """Return a tuple (Width, Height) with image dimensions.""" |
||
49 | return self.img.size |
||
50 | |||
51 | @property |
||
52 | def width(self): |
||
53 | """Return the width value of the image's dimensions.""" |
||
54 | return self.size[0] |
||
55 | |||
56 | @property |
||
57 | def height(self): |
||
58 | """Return the height value of the image's dimensions.""" |
||
59 | return self.size[1] |
||
60 | |||
61 | @property |
||
62 | def mode(self): |
||
63 | """Return the images mode.""" |
||
64 | return self.img.mode |
||
65 | |||
66 | @property |
||
67 | def correct_extension(self): |
||
68 | """Return the images mode.""" |
||
69 | return '.jpg' if self.mode != 'RGBA' else '.png' |
||
70 | |||
71 | @property |
||
72 | def longest_side(self): |
||
73 | """Return the longest side value (width or height) of the image.""" |
||
74 | return max(self.height, self.width) |
||
75 | |||
76 | def _text_centered_x(self, text, drawing, font_type): |
||
77 | """ |
||
78 | Retrieve a 'x' value that centers the text in the canvas. |
||
79 | |||
80 | :param text: String to be centered |
||
81 | :param drawing: PIL.ImageDraw.Draw instance |
||
82 | :param font_type: Registered font family type |
||
83 | :return: X coordinate value |
||
84 | """ |
||
85 | # ('Page Width' - 'Text Width') / 2 |
||
86 | return (self.width - drawing.textsize(text, font=font_type)[0]) / 2 |
||
87 | |||
88 | def _text_centered_y(self, font_size): |
||
89 | """ |
||
90 | Retrieve a 'y' value that centers the image in the canvas. |
||
91 | |||
92 | :param font_size: Font size |
||
93 | :return: Y coordinate value |
||
94 | """ |
||
95 | # ('Image Size' / 2) - 'Font Size' |
||
96 | return (self.height / 2) - font_size |
||
97 | |||
98 | def _img_centered_x(self, image): |
||
99 | """Retrieve an 'x' value that horizontally centers the image in the canvas.""" |
||
100 | return int((self.width / 2) - (image.size[0] / 2)) |
||
101 | |||
102 | def _img_centered_y(self, image): |
||
103 | """Retrieve an 'y' value that vertically centers the image in the canvas.""" |
||
104 | return int((self.height / 2) - (image.size[1] / 2)) |
||
105 | |||
106 | def image_bound(self, image, x, y): |
||
107 | """ |
||
108 | Calculate the image bounds. |
||
109 | |||
110 | If 'center' is found in x or y, a value that centers the image is calculated. |
||
111 | If a x or y value is negative, values are calculated as that distance from the right/bottom. |
||
112 | |||
113 | |||
114 | :param image: Image to-be pasted |
||
115 | :param x: |
||
116 | :param y: |
||
117 | :return: X and Y values |
||
118 | """ |
||
119 | def calculator(value, img_size, center_func): |
||
120 | """Helper function to perform bound calculations for either x or y values.""" |
||
121 | # Center the image |
||
122 | if 'center' in str(value).lower(): |
||
123 | return center_func(image) |
||
124 | |||
125 | # Percentage value, calculate based on percentages |
||
126 | elif 0 < float(value) < 1: |
||
127 | return int(img_size * float(value)) |
||
128 | |||
129 | # Negative value, calculate distance from far edge (Right, Bottom |
||
130 | elif int(value) < 0: |
||
131 | return int(img_size - abs(value)) |
||
132 | else: |
||
133 | return int(value) |
||
134 | |||
135 | return (abs(calculator(x, self.width, |
||
136 | self._img_centered_x)), abs(calculator(y, self.height, self._img_centered_y))) |
||
137 | |||
138 | def scale_to_fit(self, img, func='min', scale=None, multiplier=float(1)): |
||
139 | """ |
||
140 | Scale an image to fit the Pillow canvas. |
||
141 | |||
142 | :param img: Image object |
||
143 | :param func: Scale calculation function |
||
144 | :param scale: Specific scale |
||
145 | :param multiplier: Value to multiple calculated scale by |
||
146 | :return: |
||
147 | """ |
||
148 | im = img if isinstance(img, Image.Image) else Image.open(img) |
||
149 | |||
150 | # Use either the shortest edge (min) or the longest edge (max) to determine scale factor |
||
151 | if not scale: |
||
152 | if func == 'min': |
||
153 | scale = min(float(self.width / im.size[0]), float(self.height / im.size[1])) |
||
154 | else: |
||
155 | scale = max(float(self.width / im.size[0]), float(self.height / im.size[1])) |
||
156 | scale = scale * multiplier |
||
157 | |||
158 | im.thumbnail((int(im.size[0] * scale), int(im.size[1] * scale))) |
||
159 | |||
160 | image = im if isinstance(img, Image.Image) else self.save(img=im) |
||
161 | im.close() |
||
162 | return image |
||
163 | |||
164 | def resize(self, longest_side): |
||
165 | """Resize by specifying the longest side length.""" |
||
166 | return self.resize_width(longest_side) if self.width > self.height else self.resize_height(longest_side) |
||
167 | |||
168 | def resize_width(self, max_width): |
||
169 | """Adjust an images width while proportionately scaling height.""" |
||
170 | width_percent = (max_width / float(self.width)) |
||
171 | height_size = int((float(self.height)) * float(width_percent)) |
||
172 | self.img = self.img.resize((max_width, height_size), Image.ANTIALIAS) |
||
173 | return self.img |
||
174 | |||
175 | def resize_height(self, max_height): |
||
176 | """Adjust an images height while proportionately scaling width.""" |
||
177 | height_percent = (max_height / float(self.height)) |
||
178 | width_size = int((float(self.width) * float(height_percent))) |
||
179 | self.img = self.img.resize((width_size, max_height), Image.ANTIALIAS) |
||
180 | return self.img |
||
181 | |||
182 | def draw_text(self, text, x='center', y=140, font=FONT, font_size=40, opacity=25): |
||
183 | """ |
||
184 | Draw text onto a Pillow image canvas. |
||
185 | |||
186 | :param text: Text string |
||
187 | :param x: X coordinate value |
||
188 | :param y: Y coordinate value |
||
189 | :param font: Registered font family |
||
190 | :param font_size: Font size |
||
191 | :param opacity: Opacity of text to be drawn |
||
192 | :return: |
||
193 | """ |
||
194 | # Set drawing context |
||
195 | d = ImageDraw.Draw(self.img) |
||
196 | |||
197 | # Set a font |
||
198 | fnt = ImageFont.truetype(font, int(font_size * 1.00)) # multiply size of font if needed |
||
199 | |||
200 | # Check if x or y is set to 'center' |
||
201 | x = self._text_centered_x(text, d, fnt) if 'center' in str(x).lower() else x |
||
202 | y = self._text_centered_y(font_size) if 'center' in str(y).lower() else y |
||
203 | |||
204 | # Draw text to image |
||
205 | opacity = int(opacity * 100) if opacity < 1 else opacity |
||
206 | d.text((x, y), text, font=fnt, fill=(0, 0, 0, opacity)) |
||
207 | |||
208 | def draw_img(self, |
||
209 | img, |
||
210 | x='center', |
||
211 | y='center', |
||
212 | opacity=1.0, |
||
213 | rotate=0, |
||
214 | fit=1, |
||
215 | scale_to_fit=True, |
||
216 | scale_multiplier=float(1)): |
||
217 | """ |
||
218 | Scale an image to fit the canvas then alpha composite paste the image. |
||
219 | |||
220 | Optionally place the image (x, y), adjust the images opacity |
||
221 | or apply a rotation. |
||
222 | |||
223 | :param img: Path to image to paste |
||
224 | :param x: X coordinates value (Left) |
||
225 | :param y: Y coordinates value (Top) |
||
226 | :param opacity: Opacity value |
||
227 | :param rotate: Rotation degrees |
||
228 | :param fit: When true, expands image canvas size to fit rotated image |
||
229 | :param scale_to_fit: When true, image is scaled to fit canvas size |
||
230 | :param scale_multiplier: Value to multiple calculated scale by |
||
231 | :return: |
||
232 | """ |
||
233 | img = img_adjust(img, opacity, rotate, fit, self.tempdir.name) |
||
234 | with Image.open(self.scale_to_fit(img, multiplier=scale_multiplier) if scale_to_fit else img) as image: |
||
235 | x, y = self.image_bound(image, x, y) |
||
236 | self.img.alpha_composite(image, (x, y)) |
||
237 | |||
238 | def rotate(self, rotate): |
||
239 | # Create transparent image that is the same size as self.img |
||
240 | mask = Image.new('L', self.img.size, 255) |
||
241 | |||
242 | # Rotate image and then scale image to fit self.img |
||
243 | front = self.img.rotate(rotate, expand=True) |
||
244 | |||
245 | # Rotate mask |
||
246 | mask.rotate(rotate, expand=True) |
||
247 | |||
248 | # Determine difference in size between mask and front |
||
249 | y_margin = int((mask.size[1] - front.size[1]) / 3) |
||
250 | |||
251 | # Create another new image |
||
252 | rotated = Image.new('RGBA', self.img.size, color=(255, 255, 255, 0)) |
||
253 | |||
254 | # Paste front into new image and set x offset equal to half |
||
255 | # the difference of front and mask size |
||
256 | rotated.paste(front, (0, y_margin)) |
||
257 | self.img = rotated |
||
258 | |||
259 | def save(self, img=None, destination=None, file_name='pil', ext='.png'): |
||
260 | img = self.img if not img else img |
||
261 | if destination: |
||
262 | output = os.path.join(destination, Path(file_name).stem + ext) |
||
263 | elif self.tempdir: |
||
264 | tmpimg = NamedTemporaryFile(suffix='.png', dir=self.tempdir.name, delete=False) |
||
265 | output = resource_path(tmpimg.name) |
||
266 | tmpimg.close() |
||
267 | else: |
||
268 | output = os.path.join(bundle_dir(), file_name + ext) |
||
269 | |||
270 | # Save image file |
||
271 | img.save(output) |
||
272 | return output |
||
273 | |||
274 | def show(self): |
||
275 | """Display a Pillow image on your operating system.""" |
||
276 | return self.img.show() |
||
277 | |||
278 | def cleanup(self): |
||
279 | """Implicitly delete temporary directories that have been created.""" |
||
280 | self.tempdir.cleanup() |
||
281 |