Coverage for adhoc-cicd-odoo-odoo / odoo / tools / image.py: 43%

249 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:15 +0000

1# -*- coding: utf-8 -*- 

2# Part of Odoo. See LICENSE file for full copyright and licensing details. 

3import base64 

4import binascii 

5import io 

6from typing import Tuple, Union 

7 

8from PIL import Image, ImageOps 

9# We can preload Ico too because it is considered safe 

10from PIL import IcoImagePlugin 

11try: 

12 from PIL.Image import Transpose, Palette, Resampling 

13except ImportError: 

14 Transpose = Palette = Resampling = Image 

15 

16from random import randrange 

17 

18from odoo.exceptions import UserError 

19from odoo.tools.misc import DotDict 

20from odoo.tools.translate import LazyTranslate 

21 

22 

23__all__ = ["image_process"] 

24_lt = LazyTranslate('base') 

25 

26# Preload PIL with the minimal subset of image formats we need 

27Image.preinit() 

28Image._initialized = 2 

29 

30# Maps only the 6 first bits of the base64 data, accurate enough 

31# for our purpose and faster than decoding the full blob first 

32FILETYPE_BASE64_MAGICWORD = { 

33 b'/': 'jpg', 

34 b'R': 'gif', 

35 b'i': 'png', 

36 b'P': 'svg+xml', 

37 b'U': 'webp', 

38} 

39 

40EXIF_TAG_ORIENTATION = 0x112 

41# The target is to have 1st row/col to be top/left 

42# Note: rotate is counterclockwise 

43EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS = { # Initial side on 1st row/col: 

44 0: [], # reserved 

45 1: [], # top/left 

46 2: [Transpose.FLIP_LEFT_RIGHT], # top/right 

47 3: [Transpose.ROTATE_180], # bottom/right 

48 4: [Transpose.FLIP_TOP_BOTTOM], # bottom/left 

49 5: [Transpose.FLIP_LEFT_RIGHT, Transpose.ROTATE_90],# left/top 

50 6: [Transpose.ROTATE_270], # right/top 

51 7: [Transpose.FLIP_TOP_BOTTOM, Transpose.ROTATE_90],# right/bottom 

52 8: [Transpose.ROTATE_90], # left/bottom 

53} 

54 

55# Arbitrary limit to fit most resolutions, including Samsung Galaxy A22 photo, 

56# 8K with a ratio up to 16:10, and almost all variants of 4320p 

57IMAGE_MAX_RESOLUTION = 50e6 

58 

59 

60class ImageProcess: 

61 

62 def __init__(self, source, verify_resolution=True): 

63 """Initialize the ``source`` image for processing. 

64 

65 :param bytes source: the original image binary 

66 

67 No processing will be done if the `source` is falsy or if 

68 the image is SVG. 

69 :param verify_resolution: if True, make sure the original image size is not 

70 excessive before starting to process it. The max allowed resolution is 

71 defined by `IMAGE_MAX_RESOLUTION`. 

72 :type verify_resolution: bool 

73 :rtype: ImageProcess 

74 

75 :raise: ValueError if `verify_resolution` is True and the image is too large 

76 :raise: UserError if the image can't be identified by PIL 

77 """ 

78 self.source = source or False 

79 self.operationsCount = 0 

80 

81 if not source or source[:1] == b'<' or (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'): 

82 # don't process empty source or SVG or WEBP 

83 self.image = False 

84 else: 

85 try: 

86 self.image = Image.open(io.BytesIO(source)) 

87 except (OSError, binascii.Error): 

88 raise UserError(_lt("This file could not be decoded as an image file.")) 

89 

90 # Original format has to be saved before fixing the orientation or 

91 # doing any other operations because the information will be lost on 

92 # the resulting image. 

93 self.original_format = (self.image.format or '').upper() 

94 

95 self.image = image_fix_orientation(self.image) 

96 

97 w, h = self.image.size 

98 if verify_resolution and w * h > IMAGE_MAX_RESOLUTION: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 raise UserError(_lt("Too large image (above %sMpx), reduce the image size.", str(IMAGE_MAX_RESOLUTION / 1e6))) 

100 

101 def image_quality(self, quality=0, output_format=''): 

102 """Return the image resulting of all the image processing 

103 operations that have been applied previously. 

104 

105 The source is returned as-is if it's an SVG, or if no operations have 

106 been applied, the `output_format` is the same as the original format, 

107 and the quality is not specified. 

108 

109 :param int quality: quality setting to apply. Default to 0. 

110 

111 - for JPEG: 1 is worse, 95 is best. Values above 95 should be 

112 avoided. Falsy values will fallback to 95, but only if the image 

113 was changed, otherwise the original image is returned. 

114 - for PNG: set falsy to prevent conversion to a WEB palette. 

115 - for other formats: no effect. 

116 

117 :param str output_format: Can be PNG, JPEG, GIF, or ICO. 

118 Default to the format of the original image if a valid output format, 

119 otherwise BMP is converted to PNG and the rest are converted to JPEG. 

120 :return: the final image, or ``False`` if the original ``source`` was falsy. 

121 :rtype: bytes | False 

122 """ 

123 if not self.image: 

124 return self.source 

125 

126 output_image = self.image 

127 

128 output_format = output_format.upper() or self.original_format 

129 if output_format == 'BMP': 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 output_format = 'PNG' 

131 elif output_format not in ['PNG', 'JPEG', 'GIF', 'ICO']: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

132 output_format = 'JPEG' 

133 

134 if not self.operationsCount and output_format == self.original_format and not quality: 

135 return self.source 

136 

137 opt = {'output_format': output_format} 

138 

139 if output_format == 'PNG': 

140 opt['optimize'] = True 

141 if quality: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

142 if output_image.mode != 'P': 

143 # Floyd Steinberg dithering by default 

144 output_image = output_image.convert('RGBA').convert('P', palette=Palette.WEB, colors=256) 

145 if output_format == 'JPEG': 

146 opt['optimize'] = True 

147 opt['quality'] = quality or 95 

148 if output_format == 'GIF': 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 opt['optimize'] = True 

150 opt['save_all'] = True 

151 

152 if output_image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (output_format == 'JPEG' and output_image.mode == 'RGBA'): 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 output_image = output_image.convert("RGB") 

154 

155 output_bytes = image_apply_opt(output_image, **opt) 

156 if len(output_bytes) >= len(self.source) and self.original_format == output_format and not self.operationsCount: 156 ↛ 159line 156 didn't jump to line 159 because the condition on line 156 was never true

157 # Format has not changed and image content is unchanged but the 

158 # reached binary is bigger: rather use the original. 

159 return self.source 

160 return output_bytes 

161 

162 def resize(self, max_width=0, max_height=0, expand=False): 

163 """Resize the image. 

164 

165 The image is not resized above the current image size, unless the expand 

166 parameter is True. This method is used by default to create smaller versions 

167 of the image. 

168 

169 The current ratio is preserved. To change the ratio, see `crop_resize`. 

170 

171 If `max_width` or `max_height` is falsy, it will be computed from the 

172 other to keep the current ratio. If both are falsy, no resize is done. 

173 

174 It is currently not supported for GIF because we do not handle all the 

175 frames properly. 

176 

177 :param int max_width: max width 

178 :param int max_height: max height 

179 :param bool expand: whether or not the image size can be increased 

180 :return: self to allow chaining 

181 :rtype: ImageProcess 

182 """ 

183 if self.image and self.original_format != 'GIF' and (max_width or max_height): 

184 w, h = self.image.size 

185 asked_width = max_width or (w * max_height) // h 

186 asked_height = max_height or (h * max_width) // w 

187 if expand and (asked_width > w or asked_height > h): 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 self.image = self.image.resize((asked_width, asked_height)) 

189 self.operationsCount += 1 

190 return self 

191 if asked_width != w or asked_height != h: 

192 self.image.thumbnail((asked_width, asked_height), Resampling.LANCZOS) 

193 if self.image.width != w or self.image.height != h: 

194 self.operationsCount += 1 

195 return self 

196 

197 def crop_resize(self, max_width, max_height, center_x=0.5, center_y=0.5): 

198 """Crop and resize the image. 

199 

200 The image is never resized above the current image size. This method is 

201 only to create smaller versions of the image. 

202 

203 Instead of preserving the ratio of the original image like `resize`, 

204 this method will force the output to take the ratio of the given 

205 `max_width` and `max_height`, so both have to be defined. 

206 

207 The crop is done before the resize in order to preserve as much of the 

208 original image as possible. The goal of this method is primarily to 

209 resize to a given ratio, and it is not to crop unwanted parts of the 

210 original image. If the latter is what you want to do, you should create 

211 another method, or directly use the `crop` method from PIL. 

212 

213 It is currently not supported for GIF because we do not handle all the 

214 frames properly. 

215 

216 :param int max_width: max width 

217 :param int max_height: max height 

218 :param float center_x: the center of the crop between 0 (left) and 1 

219 (right). Defaults to 0.5 (center). 

220 :param float center_y: the center of the crop between 0 (top) and 1 

221 (bottom). Defaults to 0.5 (center). 

222 :return: self to allow chaining 

223 :rtype: ImageProcess 

224 """ 

225 if self.image and self.original_format != 'GIF' and max_width and max_height: 

226 w, h = self.image.size 

227 # We want to keep as much of the image as possible -> at least one 

228 # of the 2 crop dimensions always has to be the same value as the 

229 # original image. 

230 # The target size will be reached with the final resize. 

231 if w / max_width > h / max_height: 

232 new_w, new_h = w, (max_height * w) // max_width 

233 else: 

234 new_w, new_h = (max_width * h) // max_height, h 

235 

236 # No cropping above image size. 

237 if new_w > w: 

238 new_w, new_h = w, (new_h * w) // new_w 

239 if new_h > h: 

240 new_w, new_h = (new_w * h) // new_h, h 

241 

242 # Dimensions should be at least 1. 

243 new_w, new_h = max(new_w, 1), max(new_h, 1) 

244 

245 # Correctly place the center of the crop. 

246 x_offset = int((w - new_w) * center_x) 

247 h_offset = int((h - new_h) * center_y) 

248 

249 if new_w != w or new_h != h: 

250 self.image = self.image.crop((x_offset, h_offset, x_offset + new_w, h_offset + new_h)) 

251 if self.image.width != w or self.image.height != h: 

252 self.operationsCount += 1 

253 

254 return self.resize(max_width, max_height) 

255 

256 def colorize(self, color=None): 

257 """Replace the transparent background by a given color, or by a random one. 

258 

259 :param tuple color: RGB values for the color to use 

260 :return: self to allow chaining 

261 :rtype: ImageProcess 

262 """ 

263 if color is None: 

264 color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24)) 

265 if self.image: 

266 original = self.image 

267 self.image = Image.new('RGB', original.size) 

268 self.image.paste(color, box=(0, 0) + original.size) 

269 self.image.paste(original, mask=original) 

270 self.operationsCount += 1 

271 return self 

272 

273 def add_padding(self, padding): 

274 """Expand the image size by adding padding around the image 

275 

276 :param int padding: thickness of the padding 

277 :return: self to allow chaining 

278 :rtype: ImageProcess 

279 """ 

280 if self.image: 

281 img_width, img_height = self.image.size 

282 self.image = self.image.resize((img_width - 2 * padding, img_height - 2 * padding)) 

283 self.image = ImageOps.expand(self.image, border=padding) 

284 self.operationsCount += 1 

285 return self 

286 

287 

288def image_process(source, size=(0, 0), verify_resolution=False, quality=0, expand=False, crop=None, colorize=False, output_format='', padding=False): 

289 """Process the `source` image by executing the given operations and 

290 return the result image. 

291 """ 

292 if not source or ((not size or (not size[0] and not size[1])) and not verify_resolution and not quality and not crop and not colorize and not output_format and not padding): 

293 # for performance: don't do anything if the image is falsy or if 

294 # no operations have been requested 

295 return source 

296 

297 image = ImageProcess(source, verify_resolution) 

298 if size: 298 ↛ 309line 298 didn't jump to line 309 because the condition on line 298 was always true

299 if crop: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true

300 center_x = 0.5 

301 center_y = 0.5 

302 if crop == 'top': 

303 center_y = 0 

304 elif crop == 'bottom': 

305 center_y = 1 

306 image.crop_resize(max_width=size[0], max_height=size[1], center_x=center_x, center_y=center_y) 

307 else: 

308 image.resize(max_width=size[0], max_height=size[1], expand=expand) 

309 if padding: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 image.add_padding(padding) 

311 if colorize: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 image.colorize(colorize if isinstance(colorize, tuple) else None) 

313 return image.image_quality(quality=quality, output_format=output_format) 

314 

315 

316# ---------------------------------------- 

317# Misc image tools 

318# --------------------------------------- 

319 

320def average_dominant_color(colors, mitigate=175, max_margin=140): 

321 """This function is used to calculate the dominant colors when given a list of colors 

322 

323 There are 5 steps: 

324 

325 1) Select dominant colors (highest count), isolate its values and remove 

326 it from the current color set. 

327 2) Set margins according to the prevalence of the dominant color. 

328 3) Evaluate the colors. Similar colors are grouped in the dominant set 

329 while others are put in the "remaining" list. 

330 4) Calculate the average color for the dominant set. This is done by 

331 averaging each band and joining them into a tuple. 

332 5) Mitigate final average and convert it to hex 

333 

334 :param colors: list of tuples having: 

335 

336 0. color count in the image 

337 1. actual color: tuple(R, G, B, A) 

338 

339 -> these can be extracted from a PIL image using 

340 :meth:`~PIL.Image.Image.getcolors` 

341 :param mitigate: maximum value a band can reach 

342 :param max_margin: maximum difference from one of the dominant values 

343 :returns: a tuple with two items: 

344 

345 0. the average color of the dominant set as: tuple(R, G, B) 

346 1. list of remaining colors, used to evaluate subsequent dominant colors 

347 """ 

348 dominant_color = max(colors) 

349 dominant_rgb = dominant_color[1][:3] 

350 dominant_set = [dominant_color] 

351 remaining = [] 

352 

353 margins = [max_margin * (1 - dominant_color[0] / 

354 sum([col[0] for col in colors]))] * 3 

355 

356 colors.remove(dominant_color) 

357 

358 for color in colors: 

359 rgb = color[1] 

360 if (rgb[0] < dominant_rgb[0] + margins[0] and rgb[0] > dominant_rgb[0] - margins[0] and 

361 rgb[1] < dominant_rgb[1] + margins[1] and rgb[1] > dominant_rgb[1] - margins[1] and 

362 rgb[2] < dominant_rgb[2] + margins[2] and rgb[2] > dominant_rgb[2] - margins[2]): 

363 dominant_set.append(color) 

364 else: 

365 remaining.append(color) 

366 

367 dominant_avg = [] 

368 for band in range(3): 

369 avg = total = 0 

370 for color in dominant_set: 

371 avg += color[0] * color[1][band] 

372 total += color[0] 

373 dominant_avg.append(int(avg / total)) 

374 

375 final_dominant = [] 

376 brightest = max(dominant_avg) 

377 for color in range(3): 

378 value = dominant_avg[color] / (brightest / mitigate) if brightest > mitigate else dominant_avg[color] 

379 final_dominant.append(int(value)) 

380 

381 return tuple(final_dominant), remaining 

382 

383 

384def image_fix_orientation(image): 

385 """Fix the orientation of the image if it has an EXIF orientation tag. 

386 

387 This typically happens for images taken from a non-standard orientation 

388 by some phones or other devices that are able to report orientation. 

389 

390 The specified transposition is applied to the image before all other 

391 operations, because all of them expect the image to be in its final 

392 orientation, which is the case only when the first row of pixels is the top 

393 of the image and the first column of pixels is the left of the image. 

394 

395 Moreover the EXIF tags will not be kept when the image is later saved, so 

396 the transposition has to be done to ensure the final image is correctly 

397 orientated. 

398 

399 Note: to be completely correct, the resulting image should have its exif 

400 orientation tag removed, since the transpositions have been applied. 

401 However since this tag is not used in the code, it is acceptable to 

402 save the complexity of removing it. 

403 

404 :param image: the source image 

405 :type image: ~PIL.Image.Image 

406 :return: the resulting image, copy of the source, with orientation fixed 

407 or the source image if no operation was applied 

408 :rtype: ~PIL.Image.Image 

409 """ 

410 getexif = getattr(image, 'getexif', None) or getattr(image, '_getexif', None) # support PIL < 6.0 

411 if getexif: 411 ↛ 418line 411 didn't jump to line 418 because the condition on line 411 was always true

412 exif = getexif() 

413 if exif: 

414 orientation = exif.get(EXIF_TAG_ORIENTATION, 0) 

415 for method in EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS.get(orientation, []): 415 ↛ 416line 415 didn't jump to line 416 because the loop on line 415 never started

416 image = image.transpose(method) 

417 return image 

418 return image 

419 

420 

421def binary_to_image(source): 

422 try: 

423 return Image.open(io.BytesIO(source)) 

424 except (OSError, binascii.Error): 

425 raise UserError(_lt("This file could not be decoded as an image file.")) 

426 

427def base64_to_image(base64_source: Union[str, bytes]) -> Image: 

428 """Return a PIL image from the given `base64_source`. 

429 

430 :param base64_source: the image base64 encoded 

431 :raise: UserError if the base64 is incorrect or the image can't be identified by PIL 

432 """ 

433 try: 

434 return Image.open(io.BytesIO(base64.b64decode(base64_source))) 

435 except (OSError, binascii.Error): 

436 raise UserError(_lt("This file could not be decoded as an image file.")) 

437 

438 

439def image_apply_opt(image: Image, output_format: str, **params) -> bytes: 

440 """Return the serialization of the provided `image` to `output_format` 

441 using `params`. 

442 

443 :param image: the image to encode 

444 :param output_format: :meth:`~PIL.Image.Image.save`'s ``format`` parameter 

445 :param dict params: params to expand when calling :meth:`~PIL.Image.Image.save` 

446 :return: the image formatted 

447 """ 

448 if output_format == 'JPEG' and image.mode not in ['1', 'L', 'RGB']: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 image = image.convert("RGB") 

450 stream = io.BytesIO() 

451 image.save(stream, format=output_format, **params) 

452 return stream.getvalue() 

453 

454 

455def image_to_base64(image, output_format, **params): 

456 """Return a base64_image from the given PIL `image` using `params`. 

457 

458 :type image: ~PIL.Image.Image 

459 :param str output_format: 

460 :param dict params: params to expand when calling :meth:`~PIL.Image.Image.save` 

461 :return: the image base64 encoded 

462 :rtype: bytes 

463 """ 

464 stream = image_apply_opt(image, output_format, **params) 

465 return base64.b64encode(stream) 

466 

467 

468def get_webp_size(source): 

469 """ 

470 Returns the size of the provided webp binary source for VP8, VP8X and 

471 VP8L, otherwise returns None. 

472 See https://developers.google.com/speed/webp/docs/riff_container. 

473 

474 :param source: binary source 

475 :return: (width, height) tuple, or None if not supported 

476 """ 

477 if not (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'): 

478 raise UserError(_lt("This file is not a webp file.")) 

479 

480 vp8_type = source[15] 

481 if vp8_type == 0x20: # 0x20 = ' ' 

482 # Sizes on big-endian 16 bits at offset 26. 

483 width_low, width_high, height_low, height_high = source[26:30] 

484 width = (width_high << 8) + width_low 

485 height = (height_high << 8) + height_low 

486 return (width, height) 

487 elif vp8_type == 0x58: # 0x48 = 'X' 

488 # Sizes (minus one) on big-endian 24 bits at offset 24. 

489 width_low, width_medium, width_high, height_low, height_medium, height_high = source[24:30] 

490 width = 1 + (width_high << 16) + (width_medium << 8) + width_low 

491 height = 1 + (height_high << 16) + (height_medium << 8) + height_low 

492 return (width, height) 

493 elif vp8_type == 0x4C and source[20] == 0x2F: # 0x4C = 'L' 

494 # Sizes (minus one) on big-endian-ish 14 bits at offset 21. 

495 # E.g. [@20] 2F ab cd ef gh 

496 # - width = 1 + (c&0x3)d ab: ignore the two high bits of the second byte 

497 # - height= 1 + hef(c&0xC>>2): used them as the first two bits of the height 

498 ab, cd, ef, gh = source[21:25] 

499 width = 1 + ((cd & 0x3F) << 8) + ab 

500 height = 1 + ((gh & 0xF) << 10) + (ef << 2) + (cd >> 6) 

501 return (width, height) 

502 return None 

503 

504 

505def is_image_size_above(base64_source_1, base64_source_2): 

506 """Return whether or not the size of the given image `base64_source_1` is 

507 above the size of the given image `base64_source_2`. 

508 """ 

509 if not base64_source_1 or not base64_source_2: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true

510 return False 

511 if base64_source_1[:1] in (b'P', 'P') or base64_source_2[:1] in (b'P', 'P'): 511 ↛ 513line 511 didn't jump to line 513 because the condition on line 511 was never true

512 # False for SVG 

513 return False 

514 

515 def get_image_size(base64_source): 

516 source = base64.b64decode(base64_source) 

517 if (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'): 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true

518 size = get_webp_size(source) 

519 if size: 

520 return DotDict({'width': size[0], 'height': size[0]}) 

521 else: 

522 # False for unknown WEBP format 

523 return False 

524 else: 

525 return image_fix_orientation(binary_to_image(source)) 

526 

527 image_source = get_image_size(base64_source_1) 

528 image_target = get_image_size(base64_source_2) 

529 return image_source.width > image_target.width or image_source.height > image_target.height 

530 

531 

532def image_guess_size_from_field_name(field_name: str) -> Tuple[int, int]: 

533 """Attempt to guess the image size based on `field_name`. 

534 

535 If it can't be guessed or if it is a custom field: return (0, 0) instead. 

536 

537 :param field_name: the name of a field 

538 :return: the guessed size 

539 """ 

540 if field_name == 'image': 

541 return (1024, 1024) 

542 if field_name.startswith('x_'): 

543 return (0, 0) 

544 try: 

545 suffix = int(field_name.rsplit('_', 1)[-1]) 

546 except ValueError: 

547 return 0, 0 

548 

549 if suffix < 16: 

550 # If the suffix is less than 16, it's probably not the size 

551 return (0, 0) 

552 

553 return (suffix, suffix) 

554 

555 

556def image_data_uri(base64_source: bytes) -> str: 

557 """This returns data URL scheme according RFC 2397 

558 (https://tools.ietf.org/html/rfc2397) for all kind of supported images 

559 (PNG, GIF, JPG and SVG), defaulting on PNG type if not mimetype detected. 

560 """ 

561 return 'data:image/%s;base64,%s' % ( 

562 FILETYPE_BASE64_MAGICWORD.get(base64_source[:1], 'png'), 

563 base64_source.decode(), 

564 ) 

565 

566 

567def get_saturation(rgb): 

568 """Returns the saturation (hsl format) of a given rgb color 

569 

570 :param rgb: rgb tuple or list 

571 :return: saturation 

572 """ 

573 c_max = max(rgb) / 255 

574 c_min = min(rgb) / 255 

575 d = c_max - c_min 

576 return 0 if d == 0 else d / (1 - abs(c_max + c_min - 1)) 

577 

578 

579def get_lightness(rgb): 

580 """Returns the lightness (hsl format) of a given rgb color 

581 

582 :param rgb: rgb tuple or list 

583 :return: lightness 

584 """ 

585 return (max(rgb) + min(rgb)) / 2 / 255 

586 

587 

588def hex_to_rgb(hx): 

589 """Converts an hexadecimal string (starting with '#') to a RGB tuple""" 

590 return tuple([int(hx[i:i+2], 16) for i in range(1, 6, 2)]) 

591 

592 

593def rgb_to_hex(rgb): 

594 """Converts a RGB tuple or list to an hexadecimal string""" 

595 return '#' + ''.join([(hex(c).split('x')[-1].zfill(2)) for c in rgb])