Coverage for gws-app/gws/lib/image/__init__.py: 84%
173 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
1"""Wrapper for PIL objects"""
3import base64
4import io
5import re
6from typing import Optional, cast
8import PIL.Image
9import PIL.ImageDraw
10import PIL.ImageFont
11import numpy as np
12import qrcode.main
13import qrcode.constants
15import gws
16import gws.lib.mime
18# https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.open
19# max 10k x 10k RGBA
20PIL.Image.MAX_IMAGE_PIXELS = 10_000 * 10_000 * 4
23class Error(gws.Error):
24 pass
27class FormatConfig(gws.Config):
28 """Image format configuration."""
30 mimeTypes: list[gws.MimeType]
31 """Mime types for this format."""
32 options: Optional[dict]
33 """Image options."""
36def from_size(size: gws.Size, color=None) -> 'Image':
37 """Creates a monochrome image object.
39 Args:
40 size: `(width, height)`
41 color: `(red, green, blue, alpha)`
43 Returns:
44 An image object.
45 """
46 img = PIL.Image.new('RGBA', _int_size(size), color or (0, 0, 0, 0))
47 return _new(img)
50def from_bytes(r: bytes) -> 'Image':
51 """Creates an image object from bytes.
53 Args:
54 r: Bytes encoding an image.
56 Returns:
57 An image object.
58 """
59 with io.BytesIO(r) as fp:
60 return _new(PIL.Image.open(fp))
63def from_raw_data(r: bytes, mode: str, size: gws.Size) -> 'Image':
64 """Creates an image object in a given mode from raw pixel data in arrays.
66 Args:
67 r: Bytes encoding an image in arrays of pixels.
68 mode: PIL image mode.
69 size: `(width, height)`
71 Returns:
72 An image object.
73 """
74 return _new(PIL.Image.frombytes(mode, _int_size(size), r))
77def from_path(path: str) -> 'Image':
78 """Creates an image object from a path.
80 Args:
81 path: Path to an existing image.
83 Returns:
84 An image object.
85 """
86 with open(path, 'rb') as fp:
87 return from_bytes(fp.read())
90_DATA_URL_RE = r'data:image/(png|gif|jpeg|jpg);base64,'
93def from_data_url(url: str) -> Optional['Image']:
94 """Creates an image object from a URL.
96 Args:
97 url: URL encoding an image.
99 Returns:
100 An image object.
101 """
102 m = re.match(_DATA_URL_RE, url)
103 if not m:
104 raise Error(f'invalid data url')
105 r = base64.standard_b64decode(url[m.end() :])
106 return from_bytes(r)
109def from_svg(xmlstr: str, size: gws.Size, mime=None) -> 'Image':
110 """Not implemented yet. Should create an image object from a URL.
112 Args:
113 xmlstr: XML String of the image.
115 size: `(width, height)`
117 mime: Mime type.
119 Returns:
120 An image object.
121 """
122 # @TODO rasterize svg
123 raise NotImplemented
126def qr_code(
127 data: str,
128 level='M',
129 scale=4,
130 border=True,
131 color='black',
132 background='white',
133) -> 'Image':
134 """Creates an Image with a QR code for the given data.
136 Args:
137 data: Data to encode.
138 level: Error correction level, one of L M Q H.
139 scale: Box size in pixels.
140 border: Include a quiet zone of 4 boxes.
141 color: Foreground color.
142 background: Background color.
144 References:
145 - https://github.com/lincolnloop/python-qrcode/blob/main/README.rst#advanced-usage
147 """
149 ec_map = {
150 'L': qrcode.constants.ERROR_CORRECT_L,
151 'M': qrcode.constants.ERROR_CORRECT_M,
152 'Q': qrcode.constants.ERROR_CORRECT_Q,
153 'H': qrcode.constants.ERROR_CORRECT_H,
154 }
156 qr = qrcode.main.QRCode(
157 version=None,
158 error_correction=ec_map[level],
159 box_size=scale,
160 border=4 if border else 0,
161 )
163 qr.add_data(data)
164 qr.make(fit=True)
166 img = qr.make_image(fill_color=color, back_color=background)
167 return _new(img)
170def get_draw(img: 'Image') -> PIL.ImageDraw.ImageDraw:
171 """Returns a PIL ImageDraw object for the given image."""
173 return PIL.ImageDraw.Draw(img.img)
176def get_font(size: int = 12, font: Optional[str] = None) -> PIL.ImageFont.ImageFont | PIL.ImageFont.FreeTypeFont:
177 """Returns a PIL ImageFont object for the given size and font.
179 Args:
180 size: Font size.
181 font: Path to a TTF font file or None for default font.
182 """
184 if font:
185 return PIL.ImageFont.truetype(font, size)
186 return PIL.ImageFont.load_default()
189def _new(img: PIL.Image.Image):
190 try:
191 img.load()
192 except Exception as exc:
193 raise Error from exc
194 return Image(img)
197class Image(gws.Image):
198 """Class to convert, save and do basic manipulations on images."""
200 def __init__(self, img: PIL.Image.Image):
201 self.img: PIL.Image.Image = img
203 def mode(self):
204 return self.img.mode
206 def size(self):
207 return self.img.size
209 def resize(self, size, **kwargs):
210 kwargs.setdefault('resample', PIL.Image.Resampling.BICUBIC)
211 self.img = self.img.resize(_int_size(size), **kwargs)
212 return self
214 def rotate(self, angle, **kwargs):
215 kwargs.setdefault('resample', PIL.Image.Resampling.BICUBIC)
216 self.img = self.img.rotate(angle, **kwargs)
217 return self
219 def crop(self, box):
220 self.img = self.img.crop(box)
221 return self
223 def paste(self, other, where=None):
224 self.img.paste(cast('Image', other).img, where)
225 return self
227 def compose(self, other, opacity=1):
228 oth = cast('Image', other).img.convert('RGBA')
230 if oth.size != self.img.size:
231 oth = oth.resize(size=self.img.size, resample=PIL.Image.Resampling.BICUBIC)
233 if opacity < 1:
234 alpha = oth.getchannel('A').point(lambda x: int(x * opacity))
235 oth.putalpha(alpha)
237 self.img = PIL.Image.alpha_composite(self.img, oth)
238 return self
240 def to_bytes(self, mime=None, options=None):
241 with io.BytesIO() as fp:
242 self._save(fp, mime, options)
243 return fp.getvalue()
245 def to_base64(self, mime=None, options=None):
246 b = base64.standard_b64encode(self.to_bytes(mime, options))
247 return b.decode('ascii')
249 def to_data_url(self, mime=None, options=None):
250 mime = mime or gws.lib.mime.PNG
251 return f'data:{mime};base64,' + self.to_base64(mime, options)
253 def to_path(self, path, mime=None, options=None):
254 with open(path, 'wb') as fp:
255 self._save(fp, mime, options)
256 return path
258 def _save(self, fp, mime: str, options: dict):
259 fmt = _mime_to_format(mime)
260 opts = dict(options or {})
261 img = self.img
263 if self.img.mode == 'RGBA' and fmt == 'JPEG':
264 background = opts.pop('background', '#FFFFFF')
265 img = PIL.Image.new('RGBA', self.img.size, background)
266 img.alpha_composite(self.img)
267 img = img.convert('RGB')
269 mode = opts.pop('mode', '')
270 if mode and self.img.mode != mode:
271 img = img.convert(mode, palette=PIL.Image.Palette.ADAPTIVE)
273 img.save(fp, fmt, **opts)
275 def to_array(self):
276 return np.array(self.img)
278 def add_text(self, text, x=0, y=0, color=None):
279 self.img = self.img.convert('RGBA')
280 draw = PIL.ImageDraw.Draw(self.img)
281 font = PIL.ImageFont.load_default()
282 color = color or (0, 0, 0, 255)
283 draw.multiline_text((x, y), text, font=font, fill=color)
284 return self
286 def add_box(self, color=None):
287 self.img = self.img.convert('RGBA')
288 draw = PIL.ImageDraw.Draw(self.img)
289 color = color or (0, 0, 0, 255)
290 x, y = self.img.size
291 draw.rectangle((0, 0) + (x - 1, y - 1), outline=color)
292 return self
294 def compare_to(self, other):
295 error = 0
296 x, y = self.size()
297 for i in range(int(x)):
298 for j in range(int(y)):
299 a_r, a_g, a_b, a_a = self.img.getpixel((i, j))
300 b_r, b_g, b_b, b_a = cast(Image, other).img.getpixel((i, j))
301 error += (a_r - b_r) ** 2
302 error += (a_g - b_g) ** 2
303 error += (a_b - b_b) ** 2
304 error += (a_a - b_a) ** 2
305 return error / (4 * x * y * 255 * 255)
308_MIME_TO_FORMAT = {
309 gws.lib.mime.PNG: 'PNG',
310 gws.lib.mime.JPEG: 'JPEG',
311 gws.lib.mime.GIF: 'GIF',
312 gws.lib.mime.WEBP: 'WEBP',
313}
316def _mime_to_format(mime):
317 if not mime:
318 return 'PNG'
319 m = mime.split(';')[0].strip()
320 if m in _MIME_TO_FORMAT:
321 return _MIME_TO_FORMAT[m]
322 m = m.split('/')
323 if len(m) == 2 and m[0] == 'image':
324 return m[1].upper()
325 raise Error(f'unknown mime type {mime!r}')
328def _int_size(size: gws.Size):
329 w, h = size
330 return int(w), int(h)
333_PIXELS = {}
334_ERROR_COLOR = '#ffa1b4'
337def empty_pixel(mime: str = None):
338 return pixel(mime, '#ffffff' if mime == gws.lib.mime.JPEG else None)
341def error_pixel(mime: str = None):
342 return pixel(mime, _ERROR_COLOR)
345def pixel(mime, color):
346 fmt = _mime_to_format(mime)
347 key = fmt, str(color)
349 if key not in _PIXELS:
350 img = PIL.Image.new('RGBA' if color is None else 'RGB', (1, 1), color)
351 with io.BytesIO() as fp:
352 img.save(fp, fmt)
353 _PIXELS[key] = fp.getvalue()
355 return _PIXELS[key]