Coverage for gws-app/gws/lib/image/__init__.py: 30%
161 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
1"""Wrapper for PIL objects"""
3from typing import Optional, Literal, cast
5import base64
6import io
7import re
9import PIL.Image
10import PIL.ImageDraw
11import PIL.ImageFont
12import numpy as np
14import qrcode
16import gws
17import gws.lib.mime
19# https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.open
20# max 10k x 10k RGBA
21PIL.Image.MAX_IMAGE_PIXELS = 10_000 * 10_000 * 4
24class Error(gws.Error):
25 pass
28def from_size(size: gws.Size, color=None) -> 'Image':
29 """Creates a monochrome image object.
31 Args:
32 size: `(width, height)`
33 color: `(red, green, blue, alpha)`
35 Returns:
36 An image object.
37 """
38 img = PIL.Image.new('RGBA', _int_size(size), color or (0, 0, 0, 0))
39 return _new(img)
42def from_bytes(r: bytes) -> 'Image':
43 """Creates an image object from bytes.
45 Args:
46 r: Bytes encoding an image.
48 Returns:
49 An image object.
50 """
51 with io.BytesIO(r) as fp:
52 return _new(PIL.Image.open(fp))
55def from_raw_data(r: bytes, mode: str, size: gws.Size) -> 'Image':
56 """Creates an image object in a given mode from raw pixel data in arrays.
58 Args:
59 r: Bytes encoding an image in arrays of pixels.
60 mode: PIL image mode.
61 size: `(width, height)`
63 Returns:
64 An image object.
65 """
66 return _new(PIL.Image.frombytes(mode, _int_size(size), r))
69def from_path(path: str) -> 'Image':
70 """Creates an image object from a path.
72 Args:
73 path: Path to an existing image.
75 Returns:
76 An image object.
77 """
78 with open(path, 'rb') as fp:
79 return from_bytes(fp.read())
82_DATA_URL_RE = r'data:image/(png|gif|jpeg|jpg);base64,'
85def from_data_url(url: str) -> Optional['Image']:
86 """Creates an image object from a URL.
88 Args:
89 url: URL encoding an image.
91 Returns:
92 An image object.
93 """
94 m = re.match(_DATA_URL_RE, url)
95 if not m:
96 raise Error(f'invalid data url')
97 r = base64.standard_b64decode(url[m.end():])
98 return from_bytes(r)
101def from_svg(xmlstr: str, size: gws.Size, mime=None) -> 'Image':
102 """Not implemented yet. Should create an image object from a URL.
104 Args:
105 xmlstr: XML String of the image.
107 size: `(width, height)`
109 mime: Mime type.
111 Returns:
112 An image object.
113 """
114 # @TODO rasterize svg
115 raise NotImplemented
118def qr_code(
119 data: str,
120 level='M',
121 scale=4,
122 border=True,
123 color='black',
124 background='white',
125) -> 'Image':
126 """Creates an Image with a QR code for the given data.
128 Args:
129 data: Data to encode.
130 level: Error correction level, one of L M Q H.
131 scale: Box size in pixels.
132 border: Include a quiet zone of 4 boxes.
133 color: Foreground color.
134 background: Background color.
136 References:
137 - https://github.com/lincolnloop/python-qrcode/blob/main/README.rst#advanced-usage
139 """
141 ec_map = {
142 'L': qrcode.constants.ERROR_CORRECT_L,
143 'M': qrcode.constants.ERROR_CORRECT_M,
144 'Q': qrcode.constants.ERROR_CORRECT_Q,
145 'H': qrcode.constants.ERROR_CORRECT_H,
146 }
148 qr = qrcode.main.QRCode(
149 version=None,
150 error_correction=ec_map[level],
151 box_size=scale,
152 border=4 if border else 0,
153 )
155 qr.add_data(data)
156 qr.make(fit=True)
158 img = qr.make_image(fill_color=color, back_color=background)
159 return _new(img)
162def _new(img: PIL.Image.Image):
163 try:
164 img.load()
165 except Exception as exc:
166 raise Error from exc
167 return Image(img)
170class Image(gws.Image):
171 """Class to convert, save and do basic manipulations on images."""
173 def __init__(self, img: PIL.Image.Image):
174 self.img: PIL.Image.Image = img
176 def mode(self):
177 return self.img.mode
179 def size(self):
180 return self.img.size
182 def resize(self, size, **kwargs):
183 kwargs.setdefault('resample', PIL.Image.BICUBIC)
184 self.img = self.img.resize(_int_size(size), **kwargs)
185 return self
187 def rotate(self, angle, **kwargs):
188 kwargs.setdefault('resample', PIL.Image.BICUBIC)
189 self.img = self.img.rotate(angle, **kwargs)
190 return self
192 def crop(self, box):
193 self.img = self.img.crop(box)
194 return self
196 def paste(self, other, where=None):
197 self.img.paste(cast('Image', other).img, where)
198 return self
200 def compose(self, other, opacity=1):
201 oth = cast('Image', other).img.convert('RGBA')
203 if oth.size != self.img.size:
204 oth = oth.resize(size=self.img.size, resample=PIL.Image.BICUBIC)
206 if opacity < 1:
207 alpha = oth.getchannel('A').point(lambda x: int(x * opacity))
208 oth.putalpha(alpha)
210 self.img = PIL.Image.alpha_composite(self.img, oth)
211 return self
213 def to_bytes(self, mime=None, options=None):
214 with io.BytesIO() as fp:
215 self._save(fp, mime, options)
216 return fp.getvalue()
218 def to_base64(self, mime=None, options=None):
219 b = base64.standard_b64encode(self.to_bytes(mime, options))
220 return b.decode('ascii')
222 def to_data_url(self, mime=None, options=None):
223 mime = mime or gws.lib.mime.PNG
224 return f'data:{mime};base64,' + self.to_base64(mime, options)
226 def to_path(self, path, mime=None, options=None):
227 with open(path, 'wb') as fp:
228 self._save(fp, mime, options)
229 return path
231 def _save(self, fp, mime: str, options: dict):
232 fmt = _mime_to_format(mime)
233 opts = dict(options or {})
234 img = self.img
236 if self.img.mode == 'RGBA' and fmt == 'JPEG':
237 background = opts.pop('background', '#FFFFFF')
238 img = PIL.Image.new('RGBA', self.img.size, background)
239 img.alpha_composite(self.img)
240 img = img.convert('RGB')
242 mode = opts.pop('mode', '')
243 if mode and self.img.mode != mode:
244 img = img.convert(mode, palette=PIL.Image.ADAPTIVE)
246 img.save(fp, fmt, **opts)
248 def to_array(self):
249 return np.array(self.img)
251 def add_text(self, text, x=0, y=0, color=None):
252 self.img = self.img.convert('RGBA')
253 draw = PIL.ImageDraw.Draw(self.img)
254 font = PIL.ImageFont.load_default()
255 color = color or (0, 0, 0, 255)
256 draw.multiline_text((x, y), text, font=font, fill=color)
257 return self
259 def add_box(self, color=None):
260 self.img = self.img.convert('RGBA')
261 draw = PIL.ImageDraw.Draw(self.img)
262 color = color or (0, 0, 0, 255)
263 x, y = self.img.size
264 draw.rectangle((0, 0) + (x - 1, y - 1), outline=color) # box goes around all edges
265 return self
267 def compare_to(self, other):
268 error = 0
269 x, y = self.size()
270 for i in range(int(x)):
271 for j in range(int(y)):
272 a_r, a_g, a_b, a_a = self.img.getpixel((i, j))
273 b_r, b_g, b_b, b_a = cast(Image, other).img.getpixel((i, j))
274 error += (a_r - b_r) ** 2
275 error += (a_g - b_g) ** 2
276 error += (a_b - b_b) ** 2
277 error += (a_a - b_a) ** 2
278 return error / (3 * x * y)
281_MIME_TO_FORMAT = {
282 gws.lib.mime.PNG: 'PNG',
283 gws.lib.mime.JPEG: 'JPEG',
284 gws.lib.mime.GIF: 'GIF',
285 gws.lib.mime.WEBP: 'WEBP',
286}
289def _mime_to_format(mime):
290 if not mime:
291 return 'PNG'
292 m = mime.split(';')[0].strip()
293 if m in _MIME_TO_FORMAT:
294 return _MIME_TO_FORMAT[m]
295 m = m.split('/')
296 if len(m) == 2 and m[0] == 'image':
297 return m[1].upper()
298 raise Error(f'unknown mime type {mime!r}')
301def _int_size(size: gws.Size):
302 w, h = size
303 return int(w), int(h)
306_PIXELS = {}
307_ERROR_COLOR = '#ffa1b4'
310def empty_pixel(mime: str = None):
311 return pixel(mime, '#ffffff' if mime == gws.lib.mime.JPEG else None)
314def error_pixel(mime: str = None):
315 return pixel(mime, _ERROR_COLOR)
318def pixel(mime, color):
319 fmt = _mime_to_format(mime)
320 key = fmt, str(color)
322 if key not in _PIXELS:
323 img = PIL.Image.new('RGBA' if color is None else 'RGB', (1, 1), color)
324 with io.BytesIO() as fp:
325 img.save(fp, fmt)
326 _PIXELS[key] = fp.getvalue()
328 return _PIXELS[key]