Coverage for gws-app/gws/lib/image/__init__.py: 83%
215 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Raster images.
3Provides ``Image``, the implementation of ``gws.Image`` on top of a PIL image, and
4functions to create images from various sources and to encode them.
6Images are created with the ``from_*`` functions (an empty image of a given size, encoded
7bytes, raw pixel data, a file, a data URL or a numpy array) or with ``qr_code``. The
8``Image`` methods resize, crop, rotate, paste and compose images and encode them as bytes,
9base64, data URLs or files. The output format is selected by a MIME type; PNG is the default.
11Image sizes are limited to ``MAX_PIXELS``. ``thumbnail`` scales encoded images down,
12``pixel``, ``empty_pixel`` and ``error_pixel`` return cached encoded 1x1 images.
13``get_draw`` and ``get_font`` give access to PIL drawing on an image.
15Example::
17 img = gws.lib.image.from_size((256, 256))
18 img.compose(gws.lib.image.from_path('/data/overlay.png'), opacity=0.5)
19 png = img.to_bytes(gws.lib.mime.PNG, {'mode': 'P'})
20"""
22import base64
23import io
24import re
25from typing import Optional, cast
27import PIL.Image
28import PIL.ImageDraw
29import PIL.ImageFont
30import numpy as np
31import qrcode.main
32import qrcode.constants
34import gws
35import gws.lib.mime
37# https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.open
38# up to ~4 GB RGBA images
39MAX_PIXELS = 1_000_000_000
40"""Maximum number of pixels of an image created from a size or raw data."""
41PIL.Image.MAX_IMAGE_PIXELS = MAX_PIXELS // 2
44class Error(gws.Error):
45 """Image error."""
47 pass
50class FormatConfig(gws.Config):
51 """Image format with its encoding options."""
53 name: str = ''
54 """Name of the format."""
55 mimeTypes: list[gws.MimeType]
56 """MIME types for this format."""
57 options: Optional[dict]
58 """Image encoding options."""
61def from_size(size: gws.Size, color=None) -> 'Image':
62 """Create an RGBA image filled with one color.
64 Args:
65 size: Image size ``(width, height)``.
66 color: Fill color ``(red, green, blue, alpha)``, transparent by default.
68 Returns:
69 An image object.
71 Raises:
72 ``Error``: If the image has more than ``MAX_PIXELS`` pixels.
73 """
74 w, h = _int_size(size)
75 if w * h > MAX_PIXELS:
76 raise Error(f'image too large: {w}x{h}')
77 img = PIL.Image.new('RGBA', (w, h), color or (0, 0, 0, 0))
78 return _new(img)
81def from_bytes(r: bytes) -> 'Image':
82 """Create an image from encoded bytes.
84 Args:
85 r: Encoded image, in any format PIL can read.
87 Returns:
88 An image object.
90 Raises:
91 ``Error``: If the image data cannot be loaded.
92 """
93 with io.BytesIO(r) as fp:
94 return _new(PIL.Image.open(fp))
97def from_raw_data(r: bytes, mode: str, size: gws.Size) -> 'Image':
98 """Create an image from raw pixel data.
100 Args:
101 r: Raw pixel data.
102 mode: PIL image mode of the data.
103 size: Image size ``(width, height)``.
105 Returns:
106 An image object.
108 Raises:
109 ``Error``: If the image has more than ``MAX_PIXELS`` pixels.
110 """
112 w, h = _int_size(size)
113 if w * h > MAX_PIXELS:
114 raise Error(f'image too large: {w}x{h}')
115 return _new(PIL.Image.frombytes(mode, (w, h), r))
118def from_path(path: str) -> 'Image':
119 """Create an image from a file.
121 Args:
122 path: Path to an image file.
124 Returns:
125 An image object.
127 Raises:
128 ``Error``: If the image data cannot be loaded.
129 """
130 with open(path, 'rb') as fp:
131 return from_bytes(fp.read())
134_DATA_URL_RE = r'data:image/(png|gif|jpeg|jpg);base64,'
137def from_data_url(url: str) -> Optional['Image']:
138 """Create an image from a base64 data URL.
140 Only PNG, GIF and JPEG data URLs are accepted.
142 Args:
143 url: A ``data:image/...;base64,`` URL.
145 Returns:
146 An image object.
148 Raises:
149 ``Error``: If the URL is not a supported data URL or the image cannot be loaded.
150 """
151 m = re.match(_DATA_URL_RE, url)
152 if not m:
153 raise Error(f'invalid data url')
154 r = base64.standard_b64decode(url[m.end() :])
155 return from_bytes(r)
158def from_array(arr: np.ndarray, mode: str = None) -> 'Image':
159 """Create an image from a numpy array.
161 Args:
162 arr: Pixel array, as returned by ``Image.to_array``.
163 mode: Not used.
165 Returns:
166 An image object.
168 Raises:
169 ``Error``: If the image data cannot be loaded.
170 """
171 img = PIL.Image.fromarray(arr)
172 return _new(img)
175def from_svg(xmlstr: str, size: gws.Size, mime_type=None) -> 'Image':
176 """Create an image from an SVG document. Not implemented.
178 Args:
179 xmlstr: SVG source.
180 size: Image size ``(width, height)``.
181 mime_type: MIME type.
183 Returns:
184 An image object.
186 Raises:
187 ``NotImplementedError``: Always.
188 """
189 # @TODO rasterize svg
190 raise NotImplementedError
193def thumbnail(r: bytes, size: gws.Size, max_pixels=0, mime_type=None, options=None) -> bytes:
194 """Create a thumbnail from an encoded image.
196 The image is scaled to fit into ``size``, keeping the aspect ratio. Small images are not scaled up.
198 Args:
199 r: Encoded image.
200 size: Maximum thumbnail size ``(width, height)``.
201 max_pixels: Maximum number of source pixels, ``0`` for no limit.
202 mime_type: MIME type of the thumbnail, PNG by default.
203 options: Encoding options, as in ``Image.to_bytes``.
205 Returns:
206 The encoded thumbnail.
208 Raises:
209 ``Error``: If the image is too big or cannot be processed.
210 """
212 sz = _int_size(size)
214 try:
215 with io.BytesIO(r) as fp:
216 img = PIL.Image.open(fp)
217 w, h = img.size
218 if max_pixels and w * h > max_pixels:
219 raise Error(f'image too big: {w}x{h}')
220 img.draft(img.mode, sz)
221 img.thumbnail(sz, resample=PIL.Image.Resampling.BICUBIC)
222 if img.mode not in {'1', 'L', 'LA', 'P', 'RGB', 'RGBA'}:
223 img = img.convert('RGB')
224 return Image(img).to_bytes(mime_type, options)
225 except Error:
226 raise
227 except Exception as exc:
228 raise Error from exc
231def qr_code(
232 data: str,
233 level='M',
234 scale=4,
235 border=True,
236 color='black',
237 background='white',
238) -> 'Image':
239 """Create an image with a QR code.
241 See https://github.com/lincolnloop/python-qrcode/blob/main/README.rst#advanced-usage.
243 Args:
244 data: Data to encode.
245 level: Error correction level, one of ``L``, ``M``, ``Q``, ``H``.
246 scale: Box size in pixels.
247 border: If ``True``, include a quiet zone of 4 boxes.
248 color: Foreground color.
249 background: Background color.
251 Returns:
252 An image object.
254 Raises:
255 ``Error``: If the image cannot be created.
256 """
258 ec_map = {
259 'L': qrcode.constants.ERROR_CORRECT_L,
260 'M': qrcode.constants.ERROR_CORRECT_M,
261 'Q': qrcode.constants.ERROR_CORRECT_Q,
262 'H': qrcode.constants.ERROR_CORRECT_H,
263 }
265 qr = qrcode.main.QRCode(
266 version=None,
267 error_correction=ec_map[level],
268 box_size=scale,
269 border=4 if border else 0,
270 )
272 qr.add_data(data)
273 qr.make(fit=True)
275 img = qr.make_image(fill_color=color, back_color=background)
276 return _new(img)
279def get_draw(img: 'Image') -> PIL.ImageDraw.ImageDraw:
280 """Return a PIL drawing object for an image.
282 Args:
283 img: An image object.
285 Returns:
286 A ``PIL.ImageDraw.ImageDraw`` that draws on the image in place.
287 """
289 return PIL.ImageDraw.Draw(img.img)
292def get_font(size: int = 12, font: Optional[str] = None) -> PIL.ImageFont.ImageFont | PIL.ImageFont.FreeTypeFont:
293 """Return a PIL font object.
295 Args:
296 size: Font size, used for TrueType fonts only.
297 font: Path to a TrueType font file, or ``None`` for the PIL default font.
299 Returns:
300 A PIL font object.
301 """
303 if font:
304 return PIL.ImageFont.truetype(font, size)
305 return PIL.ImageFont.load_default()
308def _new(img: PIL.Image.Image):
309 """Load the PIL image data and wrap the image in an ``Image``."""
310 try:
311 img.load()
312 except Exception as exc:
313 raise Error from exc
314 return Image(img)
317class Image(gws.Image):
318 """Image object, a wrapper around a PIL image."""
320 def __init__(self, img: PIL.Image.Image):
321 """Create an image object.
323 Args:
324 img: A PIL image.
325 """
327 self.img: PIL.Image.Image = img
329 def mode(self):
330 return self.img.mode
332 def size(self):
333 return self.img.size
335 def resize(self, size, **kwargs):
336 kwargs.setdefault('resample', PIL.Image.Resampling.BICUBIC)
337 self.img = self.img.resize(_int_size(size), **kwargs)
338 return self
340 def resize_to(self, width=0, height=0, **kwargs):
341 w, h = self.img.size
342 if width and height:
343 sz = (width, height)
344 elif width:
345 sz = (width, int(h * width / w))
346 elif height:
347 sz = (int(w * height / h), height)
348 else:
349 return self
350 return self.resize(sz, **kwargs)
352 def rotate(self, angle, **kwargs):
353 kwargs.setdefault('resample', PIL.Image.Resampling.BICUBIC)
354 self.img = self.img.rotate(angle, **kwargs)
355 return self
357 def crop(self, box):
358 self.img = self.img.crop(box)
359 return self
361 def convert(self, mode):
362 if self.img.mode != mode:
363 self.img = self.img.convert(mode)
364 return self
366 def paste(self, other, where=None):
367 self.img.paste(cast('Image', other).img, where)
368 return self
370 def compose(self, other, opacity=1):
371 oth = cast('Image', other).img.convert('RGBA')
373 if oth.size != self.img.size:
374 oth = oth.resize(size=self.img.size, resample=PIL.Image.Resampling.BICUBIC)
376 if opacity < 1:
377 alpha = oth.getchannel('A').point(lambda x: int(x * opacity))
378 oth.putalpha(alpha)
380 self.img = PIL.Image.alpha_composite(self.img, oth)
381 return self
383 def to_bytes(self, mime_type=None, options=None):
384 with io.BytesIO() as fp:
385 self._save(fp, mime_type, options)
386 return fp.getvalue()
388 def to_base64(self, mime_type=None, options=None):
389 b = base64.standard_b64encode(self.to_bytes(mime_type, options))
390 return b.decode('ascii')
392 def to_data_url(self, mime_type=None, options=None):
393 mime_type = mime_type or gws.lib.mime.PNG
394 return f'data:{mime_type};base64,' + self.to_base64(mime_type, options)
396 def to_path(self, path, mime_type=None, options=None):
397 with open(path, 'wb') as fp:
398 self._save(fp, mime_type, options)
399 return path
401 def _save(self, fp, mime_type: str, options: dict):
402 fmt = _mime_to_format(mime_type)
403 opts = dict(options or {})
404 img = self.img
406 if self.img.mode == 'RGBA' and fmt == 'JPEG':
407 background = opts.pop('background', '#FFFFFF')
408 img = PIL.Image.new('RGBA', self.img.size, background)
409 img.alpha_composite(self.img)
410 img = img.convert('RGB')
412 mode = opts.pop('mode', '')
413 if mode and self.img.mode != mode:
414 img = img.convert(mode, palette=PIL.Image.Palette.ADAPTIVE)
416 img.save(fp, fmt, **opts)
418 def to_array(self):
419 return np.array(self.img)
421 def add_text(self, text, x=0, y=0, color=None):
422 self.img = self.img.convert('RGBA')
423 draw = PIL.ImageDraw.Draw(self.img)
424 font = PIL.ImageFont.load_default()
425 color = color or (0, 0, 0, 255)
426 draw.multiline_text((x, y), text, font=font, fill=color)
427 return self
429 def add_box(self, color=None):
430 self.img = self.img.convert('RGBA')
431 draw = PIL.ImageDraw.Draw(self.img)
432 color = color or (0, 0, 0, 255)
433 x, y = self.img.size
434 draw.rectangle((0, 0) + (x - 1, y - 1), outline=color)
435 return self
437 def compare_to(self, other):
438 error = 0
439 x, y = self.size()
440 for i in range(int(x)):
441 for j in range(int(y)):
442 a_r, a_g, a_b, a_a = self.img.getpixel((i, j))
443 b_r, b_g, b_b, b_a = cast(Image, other).img.getpixel((i, j))
444 error += (a_r - b_r) ** 2
445 error += (a_g - b_g) ** 2
446 error += (a_b - b_b) ** 2
447 error += (a_a - b_a) ** 2
448 return error / (4 * x * y * 255 * 255)
451_MIME_TO_FORMAT = {
452 gws.lib.mime.PNG: 'PNG',
453 gws.lib.mime.JPEG: 'JPEG',
454 gws.lib.mime.GIF: 'GIF',
455 gws.lib.mime.WEBP: 'WEBP',
456}
459def _mime_to_format(mime_type):
460 """Return the PIL format name for a MIME type, PNG if no MIME type is given."""
462 if not mime_type:
463 return 'PNG'
464 m = mime_type.split(';')[0].strip()
465 if m in _MIME_TO_FORMAT:
466 return _MIME_TO_FORMAT[m]
467 m = m.split('/')
468 if len(m) == 2 and m[0] == 'image':
469 return m[1].upper()
470 raise Error(f'unknown mime type {mime_type!r}')
473def _int_size(size: gws.Size):
474 w, h = size
475 return int(w), int(h)
478_PIXELS = {}
479_ERROR_COLOR = '#ffa1b4'
482def empty_pixel(mime_type: str = None):
483 """Return an encoded empty 1x1 image.
485 The pixel is transparent, or white for JPEG.
487 Args:
488 mime_type: MIME type, PNG by default.
490 Returns:
491 The encoded image.
493 Raises:
494 ``Error``: If the MIME type is not an image type.
495 """
497 return pixel(mime_type, '#ffffff' if mime_type == gws.lib.mime.JPEG else None)
500def error_pixel(mime_type: str = None):
501 """Return an encoded 1x1 image in the error color.
503 Args:
504 mime_type: MIME type, PNG by default.
506 Returns:
507 The encoded image.
509 Raises:
510 ``Error``: If the MIME type is not an image type.
511 """
513 return pixel(mime_type, _ERROR_COLOR)
516def pixel(mime_type, color):
517 """Return an encoded 1x1 image of a color.
519 Results are cached.
521 Args:
522 mime_type: MIME type, PNG by default.
523 color: Pixel color, or ``None`` for a transparent pixel.
525 Returns:
526 The encoded image.
528 Raises:
529 ``Error``: If the MIME type is not an image type.
530 """
532 fmt = _mime_to_format(mime_type)
533 key = fmt, str(color)
535 if key not in _PIXELS:
536 img = PIL.Image.new('RGBA' if color is None else 'RGB', (1, 1), color)
537 with io.BytesIO() as fp:
538 img.save(fp, fmt)
539 _PIXELS[key] = fp.getvalue()
541 return _PIXELS[key]