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

1"""Raster images. 

2 

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. 

5 

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. 

10 

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. 

14 

15Example:: 

16 

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""" 

21 

22import base64 

23import io 

24import re 

25from typing import Optional, cast 

26 

27import PIL.Image 

28import PIL.ImageDraw 

29import PIL.ImageFont 

30import numpy as np 

31import qrcode.main 

32import qrcode.constants 

33 

34import gws 

35import gws.lib.mime 

36 

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 

42 

43 

44class Error(gws.Error): 

45 """Image error.""" 

46 

47 pass 

48 

49 

50class FormatConfig(gws.Config): 

51 """Image format with its encoding options.""" 

52 

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.""" 

59 

60 

61def from_size(size: gws.Size, color=None) -> 'Image': 

62 """Create an RGBA image filled with one color. 

63 

64 Args: 

65 size: Image size ``(width, height)``. 

66 color: Fill color ``(red, green, blue, alpha)``, transparent by default. 

67 

68 Returns: 

69 An image object. 

70 

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) 

79 

80 

81def from_bytes(r: bytes) -> 'Image': 

82 """Create an image from encoded bytes. 

83 

84 Args: 

85 r: Encoded image, in any format PIL can read. 

86 

87 Returns: 

88 An image object. 

89 

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)) 

95 

96 

97def from_raw_data(r: bytes, mode: str, size: gws.Size) -> 'Image': 

98 """Create an image from raw pixel data. 

99 

100 Args: 

101 r: Raw pixel data. 

102 mode: PIL image mode of the data. 

103 size: Image size ``(width, height)``. 

104 

105 Returns: 

106 An image object. 

107 

108 Raises: 

109 ``Error``: If the image has more than ``MAX_PIXELS`` pixels. 

110 """ 

111 

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)) 

116 

117 

118def from_path(path: str) -> 'Image': 

119 """Create an image from a file. 

120 

121 Args: 

122 path: Path to an image file. 

123 

124 Returns: 

125 An image object. 

126 

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()) 

132 

133 

134_DATA_URL_RE = r'data:image/(png|gif|jpeg|jpg);base64,' 

135 

136 

137def from_data_url(url: str) -> Optional['Image']: 

138 """Create an image from a base64 data URL. 

139 

140 Only PNG, GIF and JPEG data URLs are accepted. 

141 

142 Args: 

143 url: A ``data:image/...;base64,`` URL. 

144 

145 Returns: 

146 An image object. 

147 

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) 

156 

157 

158def from_array(arr: np.ndarray, mode: str = None) -> 'Image': 

159 """Create an image from a numpy array. 

160 

161 Args: 

162 arr: Pixel array, as returned by ``Image.to_array``. 

163 mode: Not used. 

164 

165 Returns: 

166 An image object. 

167 

168 Raises: 

169 ``Error``: If the image data cannot be loaded. 

170 """ 

171 img = PIL.Image.fromarray(arr) 

172 return _new(img) 

173 

174 

175def from_svg(xmlstr: str, size: gws.Size, mime_type=None) -> 'Image': 

176 """Create an image from an SVG document. Not implemented. 

177 

178 Args: 

179 xmlstr: SVG source. 

180 size: Image size ``(width, height)``. 

181 mime_type: MIME type. 

182 

183 Returns: 

184 An image object. 

185 

186 Raises: 

187 ``NotImplementedError``: Always. 

188 """ 

189 # @TODO rasterize svg 

190 raise NotImplementedError 

191 

192 

193def thumbnail(r: bytes, size: gws.Size, max_pixels=0, mime_type=None, options=None) -> bytes: 

194 """Create a thumbnail from an encoded image. 

195 

196 The image is scaled to fit into ``size``, keeping the aspect ratio. Small images are not scaled up. 

197 

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``. 

204 

205 Returns: 

206 The encoded thumbnail. 

207 

208 Raises: 

209 ``Error``: If the image is too big or cannot be processed. 

210 """ 

211 

212 sz = _int_size(size) 

213 

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 

229 

230 

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. 

240 

241 See https://github.com/lincolnloop/python-qrcode/blob/main/README.rst#advanced-usage. 

242 

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. 

250 

251 Returns: 

252 An image object. 

253 

254 Raises: 

255 ``Error``: If the image cannot be created. 

256 """ 

257 

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 } 

264 

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 ) 

271 

272 qr.add_data(data) 

273 qr.make(fit=True) 

274 

275 img = qr.make_image(fill_color=color, back_color=background) 

276 return _new(img) 

277 

278 

279def get_draw(img: 'Image') -> PIL.ImageDraw.ImageDraw: 

280 """Return a PIL drawing object for an image. 

281 

282 Args: 

283 img: An image object. 

284 

285 Returns: 

286 A ``PIL.ImageDraw.ImageDraw`` that draws on the image in place. 

287 """ 

288 

289 return PIL.ImageDraw.Draw(img.img) 

290 

291 

292def get_font(size: int = 12, font: Optional[str] = None) -> PIL.ImageFont.ImageFont | PIL.ImageFont.FreeTypeFont: 

293 """Return a PIL font object. 

294 

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. 

298 

299 Returns: 

300 A PIL font object. 

301 """ 

302 

303 if font: 

304 return PIL.ImageFont.truetype(font, size) 

305 return PIL.ImageFont.load_default() 

306 

307 

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) 

315 

316 

317class Image(gws.Image): 

318 """Image object, a wrapper around a PIL image.""" 

319 

320 def __init__(self, img: PIL.Image.Image): 

321 """Create an image object. 

322 

323 Args: 

324 img: A PIL image. 

325 """ 

326 

327 self.img: PIL.Image.Image = img 

328 

329 def mode(self): 

330 return self.img.mode 

331 

332 def size(self): 

333 return self.img.size 

334 

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 

339 

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) 

351 

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 

356 

357 def crop(self, box): 

358 self.img = self.img.crop(box) 

359 return self 

360 

361 def convert(self, mode): 

362 if self.img.mode != mode: 

363 self.img = self.img.convert(mode) 

364 return self 

365 

366 def paste(self, other, where=None): 

367 self.img.paste(cast('Image', other).img, where) 

368 return self 

369 

370 def compose(self, other, opacity=1): 

371 oth = cast('Image', other).img.convert('RGBA') 

372 

373 if oth.size != self.img.size: 

374 oth = oth.resize(size=self.img.size, resample=PIL.Image.Resampling.BICUBIC) 

375 

376 if opacity < 1: 

377 alpha = oth.getchannel('A').point(lambda x: int(x * opacity)) 

378 oth.putalpha(alpha) 

379 

380 self.img = PIL.Image.alpha_composite(self.img, oth) 

381 return self 

382 

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() 

387 

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') 

391 

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) 

395 

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 

400 

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 

405 

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') 

411 

412 mode = opts.pop('mode', '') 

413 if mode and self.img.mode != mode: 

414 img = img.convert(mode, palette=PIL.Image.Palette.ADAPTIVE) 

415 

416 img.save(fp, fmt, **opts) 

417 

418 def to_array(self): 

419 return np.array(self.img) 

420 

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 

428 

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 

436 

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) 

449 

450 

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} 

457 

458 

459def _mime_to_format(mime_type): 

460 """Return the PIL format name for a MIME type, PNG if no MIME type is given.""" 

461 

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}') 

471 

472 

473def _int_size(size: gws.Size): 

474 w, h = size 

475 return int(w), int(h) 

476 

477 

478_PIXELS = {} 

479_ERROR_COLOR = '#ffa1b4' 

480 

481 

482def empty_pixel(mime_type: str = None): 

483 """Return an encoded empty 1x1 image. 

484 

485 The pixel is transparent, or white for JPEG. 

486 

487 Args: 

488 mime_type: MIME type, PNG by default. 

489 

490 Returns: 

491 The encoded image. 

492 

493 Raises: 

494 ``Error``: If the MIME type is not an image type. 

495 """ 

496 

497 return pixel(mime_type, '#ffffff' if mime_type == gws.lib.mime.JPEG else None) 

498 

499 

500def error_pixel(mime_type: str = None): 

501 """Return an encoded 1x1 image in the error color. 

502 

503 Args: 

504 mime_type: MIME type, PNG by default. 

505 

506 Returns: 

507 The encoded image. 

508 

509 Raises: 

510 ``Error``: If the MIME type is not an image type. 

511 """ 

512 

513 return pixel(mime_type, _ERROR_COLOR) 

514 

515 

516def pixel(mime_type, color): 

517 """Return an encoded 1x1 image of a color. 

518 

519 Results are cached. 

520 

521 Args: 

522 mime_type: MIME type, PNG by default. 

523 color: Pixel color, or ``None`` for a transparent pixel. 

524 

525 Returns: 

526 The encoded image. 

527 

528 Raises: 

529 ``Error``: If the MIME type is not an image type. 

530 """ 

531 

532 fmt = _mime_to_format(mime_type) 

533 key = fmt, str(color) 

534 

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() 

540 

541 return _PIXELS[key]