-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
/
img.py
799 lines (660 loc) · 27.9 KB
/
img.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
#!/usr/bin/env python
# License: GPLv3 Copyright: 2015-2019, Kovid Goyal <kovid at kovidgoyal.net>
import errno
import os
import shutil
import subprocess
import sys
import tempfile
from contextlib import suppress
from io import BytesIO
from qt.core import (
QBuffer, QByteArray, QColor, QImage, QImageReader, QImageWriter, QIODevice, QPixmap,
Qt, QTransform, qRgba
)
from threading import Thread
from calibre import fit_image, force_unicode
from calibre.constants import iswindows
from calibre.ptempfile import TemporaryDirectory
from calibre.utils.config_base import tweaks
from calibre.utils.filenames import atomic_rename
from calibre.utils.imghdr import what
from calibre.utils.resources import get_image_path as I
from calibre_extensions import imageops
from polyglot.builtins import string_or_bytes
# Utilities {{{
class NotImage(ValueError):
pass
def normalize_format_name(fmt):
fmt = fmt.lower()
if fmt == 'jpg':
fmt = 'jpeg'
return fmt
def get_exe_path(name):
from calibre.ebooks.pdf.pdftohtml import PDFTOHTML
base = os.path.dirname(PDFTOHTML)
if iswindows:
name += '-calibre.exe'
if not base:
return name
return os.path.join(base, name)
def load_jxr_data(data):
with TemporaryDirectory() as tdir:
if isinstance(tdir, bytes):
tdir = os.fsdecode(tdir)
with open(os.path.join(tdir, 'input.jxr'), 'wb') as f:
f.write(data)
cmd = [get_exe_path('JxrDecApp'), '-i', 'input.jxr', '-o', 'output.tif']
creationflags = subprocess.DETACHED_PROCESS if iswindows else 0
subprocess.Popen(cmd, cwd=tdir, stdout=open(os.devnull, 'wb'), stderr=subprocess.STDOUT, creationflags=creationflags).wait()
i = QImage()
if not i.load(os.path.join(tdir, 'output.tif')):
raise NotImage('Failed to convert JPEG-XR image')
return i
# }}}
# png <-> gif {{{
def png_data_to_gif_data(data):
from PIL import Image
img = Image.open(BytesIO(data))
buf = BytesIO()
if img.mode in ('p', 'P'):
transparency = img.info.get('transparency')
if transparency is not None:
img.save(buf, 'gif', transparency=transparency)
else:
img.save(buf, 'gif')
elif img.mode in ('rgba', 'RGBA'):
alpha = img.split()[3]
mask = Image.eval(alpha, lambda a: 255 if a <=128 else 0)
img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, colors=255)
img.paste(255, mask)
img.save(buf, 'gif', transparency=255)
else:
img = img.convert('P', palette=Image.ADAPTIVE)
img.save(buf, 'gif')
return buf.getvalue()
class AnimatedGIF(ValueError):
pass
def gif_data_to_png_data(data, discard_animation=False):
from PIL import Image
img = Image.open(BytesIO(data))
if img.is_animated and not discard_animation:
raise AnimatedGIF()
buf = BytesIO()
img.save(buf, 'png')
return buf.getvalue()
# }}}
# Loading images {{{
def set_image_allocation_limit(size_in_mb=1024):
with suppress(ImportError): # for people running form source
from calibre_extensions.progress_indicator import (
set_image_allocation_limit as impl,
)
impl(size_in_mb)
def null_image():
' Create an invalid image. For internal use. '
return QImage()
def image_from_data(data):
' Create an image object from data, which should be a bytestring. '
if isinstance(data, QImage):
return data
set_image_allocation_limit()
i = QImage()
if not i.loadFromData(data):
q = what(None, data)
if q == 'jxr':
return load_jxr_data(data)
raise NotImage(f'Not a valid image (detected type: {q})')
return i
def image_from_path(path):
' Load an image from the specified path. '
with open(path, 'rb') as f:
return image_from_data(f.read())
def image_from_x(x):
' Create an image from a bytestring or a path or a file like object. '
if isinstance(x, str):
return image_from_path(x)
if hasattr(x, 'read'):
return image_from_data(x.read())
if isinstance(x, (bytes, QImage)):
return image_from_data(x)
if isinstance(x, bytearray):
return image_from_data(bytes(x))
if isinstance(x, QPixmap):
return x.toImage()
raise TypeError('Unknown image src type: %s' % type(x))
def image_and_format_from_data(data):
' Create an image object from the specified data which should be a bytestring and also return the format of the image '
ba = QByteArray(data)
buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.ReadOnly)
r = QImageReader(buf)
fmt = bytes(r.format()).decode('utf-8')
return r.read(), fmt
# }}}
# Saving images {{{
def image_to_data(img, compression_quality=95, fmt='JPEG', png_compression_level=9, jpeg_optimized=True, jpeg_progressive=False):
'''
Serialize image to bytestring in the specified format.
:param compression_quality: is for JPEG and WEBP and goes from 0 to 100.
100 being lowest compression, highest image quality. For WEBP 100 means lossless with effort of 70.
:param png_compression_level: is for PNG and goes from 0-9. 9 being highest compression.
:param jpeg_optimized: Turns on the 'optimize' option for libjpeg which losslessly reduce file size
:param jpeg_progressive: Turns on the 'progressive scan' option for libjpeg which allows JPEG images to be downloaded in streaming fashion
'''
fmt = fmt.upper()
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.WriteOnly)
if fmt == 'GIF':
w = QImageWriter(buf, b'PNG')
w.setQuality(90)
if not w.write(img):
raise ValueError('Failed to export image as ' + fmt + ' with error: ' + w.errorString())
return png_data_to_gif_data(ba.data())
is_jpeg = fmt in ('JPG', 'JPEG')
w = QImageWriter(buf, fmt.encode('ascii'))
if is_jpeg:
if img.hasAlphaChannel():
img = blend_image(img)
# QImageWriter only gained the following options in Qt 5.5
if jpeg_optimized:
w.setOptimizedWrite(True)
if jpeg_progressive:
w.setProgressiveScanWrite(True)
w.setQuality(compression_quality)
elif fmt == 'PNG':
cl = min(9, max(0, png_compression_level))
w.setQuality(10 * (9-cl))
elif fmt == 'WEBP':
w.setQuality(compression_quality)
if not w.write(img):
raise ValueError('Failed to export image as ' + fmt + ' with error: ' + w.errorString())
return ba.data()
def save_image(img, path, **kw):
''' Save image to the specified path. Image format is taken from the file
extension. You can pass the same keyword arguments as for the
`image_to_data()` function. '''
fmt = path.rpartition('.')[-1]
kw['fmt'] = kw.get('fmt', fmt)
with open(path, 'wb') as f:
f.write(image_to_data(image_from_data(img), **kw))
def save_cover_data_to(
data, path=None,
bgcolor='#ffffff',
resize_to=None,
compression_quality=90,
minify_to=None,
grayscale=False,
eink=False,
letterbox=False,
letterbox_color='#000000',
data_fmt='jpeg'
):
'''
Saves image in data to path, in the format specified by the path
extension. Removes any transparency. If there is no transparency and no
resize and the input and output image formats are the same, no changes are
made.
:param data: Image data as bytestring
:param path: If None img data is returned, in JPEG format
:param data_fmt: The fmt to return data in when path is None. Defaults to JPEG
:param compression_quality: The quality of the image after compression.
Number between 1 and 100. 1 means highest compression, 100 means no
compression (lossless). When generating PNG this number is divided by 10
for the png_compression_level.
:param bgcolor: The color for transparent pixels. Must be specified in hex.
:param resize_to: A tuple (width, height) or None for no resizing
:param minify_to: A tuple (width, height) to specify maximum target size.
The image will be resized to fit into this target size. If None the
value from the tweak is used.
:param grayscale: If True, the image is converted to grayscale,
if that's not already the case.
:param eink: If True, the image is dithered down to the 16 specific shades
of gray of the eInk palette.
Works best with formats that actually support color indexing (i.e., PNG)
:param letterbox: If True, in addition to fit resize_to inside minify_to,
the image will be letterboxed (i.e., centered on a black background).
:param letterbox_color: If letterboxing is used, this is the background color
used. The default is black.
'''
fmt = normalize_format_name(data_fmt if path is None else os.path.splitext(path)[1][1:])
if isinstance(data, QImage):
img = data
changed = True
else:
img, orig_fmt = image_and_format_from_data(data)
orig_fmt = normalize_format_name(orig_fmt)
changed = fmt != orig_fmt
if resize_to is not None:
changed = True
img = img.scaled(int(resize_to[0]), int(resize_to[1]), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
owidth, oheight = img.width(), img.height()
if minify_to is None:
nwidth, nheight = tweaks['maximum_cover_size']
nwidth, nheight = max(1, nwidth), max(1, nheight)
else:
nwidth, nheight = minify_to
if letterbox:
img = blend_on_canvas(img, nwidth, nheight, bgcolor=letterbox_color)
# Check if we were minified
if oheight != nheight or owidth != nwidth:
changed = True
else:
scaled, nwidth, nheight = fit_image(owidth, oheight, nwidth, nheight)
if scaled:
changed = True
img = img.scaled(int(nwidth), int(nheight), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
if img.hasAlphaChannel():
changed = True
img = blend_image(img, bgcolor)
if grayscale and not eink:
if not img.allGray():
changed = True
img = grayscale_image(img)
if eink:
# NOTE: Keep in mind that JPG does NOT actually support indexed colors, so the JPG algorithm will then smush everything back into a 256c mess...
# Thankfully, Nickel handles PNG just fine, and we potentially generate smaller files to boot, because they can be properly color indexed ;).
img = eink_dither_image(img)
changed = True
if path is None:
return image_to_data(img, compression_quality, fmt, compression_quality // 10) if changed else data
with open(path, 'wb') as f:
f.write(image_to_data(img, compression_quality, fmt, compression_quality // 10) if changed else data)
# }}}
# Overlaying images {{{
def blend_on_canvas(img, width, height, bgcolor='#ffffff'):
' Blend the `img` onto a canvas with the specified background color and size '
w, h = img.width(), img.height()
scaled, nw, nh = fit_image(w, h, width, height)
if scaled:
img = img.scaled(int(nw), int(nh), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
w, h = nw, nh
canvas = QImage(int(width), int(height), QImage.Format.Format_RGB32)
canvas.fill(QColor(bgcolor))
overlay_image(img, canvas, (width - w)//2, (height - h)//2)
return canvas
class Canvas:
def __init__(self, width, height, bgcolor='#ffffff'):
self.img = QImage(int(width), int(height), QImage.Format.Format_RGB32)
self.img.fill(QColor(bgcolor))
def __enter__(self):
return self
def __exit__(self, *args):
pass
def compose(self, img, x=0, y=0):
img = image_from_data(img)
overlay_image(img, self.img, x, y)
def export(self, fmt='JPEG', compression_quality=95):
return image_to_data(self.img, compression_quality=compression_quality, fmt=fmt)
def create_canvas(width, height, bgcolor='#ffffff'):
'Create a blank canvas of the specified size and color '
img = QImage(int(width), int(height), QImage.Format.Format_RGB32)
img.fill(QColor(bgcolor))
return img
def overlay_image(img, canvas=None, left=0, top=0):
' Overlay the `img` onto the canvas at the specified position '
if canvas is None:
canvas = QImage(img.size(), QImage.Format.Format_RGB32)
canvas.fill(Qt.GlobalColor.white)
left, top = int(left), int(top)
imageops.overlay(img, canvas, left, top)
return canvas
def texture_image(canvas, texture):
' Repeatedly tile the image `texture` across and down the image `canvas` '
if canvas.hasAlphaChannel():
canvas = blend_image(canvas)
return imageops.texture_image(canvas, texture)
def blend_image(img, bgcolor='#ffffff'):
' Used to convert images that have semi-transparent pixels to opaque by blending with the specified color '
canvas = QImage(img.size(), QImage.Format.Format_RGB32)
canvas.fill(QColor(bgcolor))
overlay_image(img, canvas)
return canvas
# }}}
# Image borders {{{
def add_borders_to_image(img, left=0, top=0, right=0, bottom=0, border_color='#ffffff'):
img = image_from_data(img)
if not (left > 0 or right > 0 or top > 0 or bottom > 0):
return img
canvas = QImage(int(img.width() + left + right), int(img.height() + top + bottom), QImage.Format.Format_RGB32)
canvas.fill(QColor(border_color))
overlay_image(img, canvas, left, top)
return canvas
def remove_borders_from_image(img, fuzz=None):
''' Try to auto-detect and remove any borders from the image. Returns
the image itself if no borders could be removed. `fuzz` is a measure of
what colors are considered identical (must be a number between 0 and 255 in
absolute intensity units). Default is from a tweak whose default value is 10. '''
fuzz = tweaks['cover_trim_fuzz_value'] if fuzz is None else fuzz
img = image_from_data(img)
ans = imageops.remove_borders(img, int(max(0, fuzz)))
return ans if ans.size() != img.size() else img
# }}}
# Cropping/scaling of images {{{
def resize_image(img, width, height):
return img.scaled(int(width), int(height), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
def resize_to_fit(img, width, height):
img = image_from_data(img)
resize_needed, nw, nh = fit_image(img.width(), img.height(), width, height)
if resize_needed:
img = resize_image(img, nw, nh)
return resize_needed, img
def clone_image(img):
''' Returns a shallow copy of the image. However, the underlying data buffer
will be automatically copied-on-write '''
return QImage(img)
def scale_image(data, width=60, height=80, compression_quality=70, as_png=False, preserve_aspect_ratio=True):
''' Scale an image, returning it as either JPEG or PNG data (bytestring).
Transparency is alpha blended with white when converting to JPEG. Is thread
safe and does not require a QApplication. '''
# We use Qt instead of ImageMagick here because ImageMagick seems to use
# some kind of memory pool, causing memory consumption to sky rocket.
img = image_from_data(data)
if preserve_aspect_ratio:
scaled, nwidth, nheight = fit_image(img.width(), img.height(), width, height)
if scaled:
img = img.scaled(int(nwidth), int(nheight), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
else:
if img.width() != width or img.height() != height:
img = img.scaled(int(width), int(height), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
fmt = 'PNG' if as_png else 'JPEG'
w, h = img.width(), img.height()
return w, h, image_to_data(img, compression_quality=compression_quality, fmt=fmt)
def crop_image(img, x, y, width, height):
'''
Return the specified section of the image.
:param x, y: The top left corner of the crop box
:param width, height: The width and height of the crop box. Note that if
the crop box exceeds the source images dimensions, width and height will be
auto-truncated.
'''
img = image_from_data(img)
width = min(width, img.width() - x)
height = min(height, img.height() - y)
return img.copy(int(x), int(y), int(width), int(height))
# }}}
# Image transformations {{{
def grayscale_image(img):
return imageops.grayscale(image_from_data(img))
def set_image_opacity(img, alpha=0.5):
''' Change the opacity of `img`. Note that the alpha value is multiplied to
any existing alpha values, so you cannot use this function to convert a
semi-transparent image to an opaque one. For that use `blend_image()`. '''
return imageops.set_opacity(image_from_data(img), alpha)
def flip_image(img, horizontal=False, vertical=False):
return image_from_data(img).mirrored(horizontal, vertical)
def image_has_transparent_pixels(img):
' Return True iff the image has at least one semi-transparent pixel '
img = image_from_data(img)
if img.isNull():
return False
return imageops.has_transparent_pixels(img)
def rotate_image(img, degrees):
t = QTransform()
t.rotate(degrees)
return image_from_data(img).transformed(t)
def gaussian_sharpen_image(img, radius=0, sigma=3, high_quality=True):
return imageops.gaussian_sharpen(image_from_data(img), max(0, radius), sigma, high_quality)
def gaussian_blur_image(img, radius=-1, sigma=3):
return imageops.gaussian_blur(image_from_data(img), max(0, radius), sigma)
def despeckle_image(img):
return imageops.despeckle(image_from_data(img))
def oil_paint_image(img, radius=-1, high_quality=True):
return imageops.oil_paint(image_from_data(img), radius, high_quality)
def normalize_image(img):
return imageops.normalize(image_from_data(img))
def quantize_image(img, max_colors=256, dither=True, palette=''):
''' Quantize the image to contain a maximum of `max_colors` colors. By
default a palette is chosen automatically, if you want to use a fixed
palette, then pass in a list of color names in the `palette` variable. If
you, specify a palette `max_colors` is ignored. Note that it is possible
for the actual number of colors used to be less than max_colors.
:param max_colors: Max. number of colors in the auto-generated palette. Must be between 2 and 256.
:param dither: Whether to use dithering or not. dithering is almost always a good thing.
:param palette: Use a manually specified palette instead. For example: palette='red green blue #eee'
'''
img = image_from_data(img)
if img.hasAlphaChannel():
img = blend_image(img)
if palette and isinstance(palette, string_or_bytes):
palette = palette.split()
return imageops.quantize(img, int(max_colors), dither, tuple(QColor(x).rgb() for x in palette))
def eink_dither_image(img):
''' Dither the source image down to the eInk palette of 16 shades of grey,
using ImageMagick's OrderedDither algorithm.
NOTE: No need to call grayscale_image first, as this will inline a grayscaling pass if need be.
Returns a QImage in Grayscale8 pixel format.
'''
img = image_from_data(img)
if img.hasAlphaChannel():
img = blend_image(img)
return imageops.ordered_dither(img)
# }}}
# Optimization of images {{{
def run_optimizer(file_path, cmd, as_filter=False, input_data=None):
file_path = os.path.abspath(file_path)
cwd = os.path.dirname(file_path)
ext = os.path.splitext(file_path)[1]
if not ext or len(ext) > 10 or not ext.startswith('.'):
ext = '.jpg'
fd, outfile = tempfile.mkstemp(dir=cwd, suffix=ext)
try:
if as_filter:
outf = os.fdopen(fd, 'wb')
else:
os.close(fd)
iname, oname = os.path.basename(file_path), os.path.basename(outfile)
input_size = os.path.getsize(file_path)
def repl(q, r):
cmd[cmd.index(q)] = r
if not as_filter:
repl(True, iname), repl(False, oname)
stdin = subprocess.PIPE if as_filter else None
stderr = subprocess.PIPE if as_filter else subprocess.STDOUT
creationflags = subprocess.DETACHED_PROCESS if iswindows else 0
p = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=stderr, stdin=stdin, creationflags=creationflags)
stderr = p.stderr if as_filter else p.stdout
if as_filter:
src = input_data or open(file_path, 'rb')
def copy(src, dest):
try:
shutil.copyfileobj(src, dest)
finally:
src.close(), dest.close()
inw = Thread(name='CopyInput', target=copy, args=(src, p.stdin))
inw.daemon = True
inw.start()
outw = Thread(name='CopyOutput', target=copy, args=(p.stdout, outf))
outw.daemon = True
outw.start()
raw = force_unicode(stderr.read())
if p.wait() != 0:
p.stdout.close()
if as_filter:
p.stderr.close()
p.stdin.close()
return raw
else:
if as_filter:
outw.join(60.0), inw.join(60.0)
p.stdin.close()
p.stderr.close()
p.stdout.close()
try:
sz = os.path.getsize(outfile)
except OSError:
sz = 0
if sz < 1:
return '%s returned a zero size image' % cmd[0]
if sz < input_size:
shutil.copystat(file_path, outfile)
atomic_rename(outfile, file_path)
finally:
try:
os.remove(outfile)
except OSError as err:
if err.errno != errno.ENOENT:
raise
try:
os.remove(outfile + '.bak') # optipng creates these files
except OSError as err:
if err.errno != errno.ENOENT:
raise
def optimize_jpeg(file_path):
exe = get_exe_path('jpegtran')
cmd = [exe] + '-copy none -optimize -progressive -maxmemory 100M -outfile'.split() + [False, True]
return run_optimizer(file_path, cmd)
def optimize_png(file_path, level=7):
' level goes from 1 to 7 with 7 being maximum compression '
exe = get_exe_path('optipng')
cmd = [exe] + f'-fix -clobber -strip all -o{level} -out'.split() + [False, True]
return run_optimizer(file_path, cmd)
def run_cwebp(file_path, lossless, q, m, metadata):
exe = get_exe_path('cwebp')
q = max(0, min(q, 100))
m = max(0, min(m, 6))
cmd = [exe] + f'-mt -metadata {metadata} -q {q} -m {m} -o'.split() + [False, True]
if lossless:
cmd.insert(1, '-lossless')
return run_optimizer(file_path, cmd)
def optimize_webp(file_path, q=100, m=6, metadata='all'):
' metadata can be a comma seaprated list of all, none, exif, icc, xmp '
return run_cwebp(file_path, True, q, m, metadata)
def encode_jpeg(file_path, quality=80):
from calibre.utils.speedups import ReadOnlyFileBuffer
quality = max(0, min(100, int(quality)))
exe = get_exe_path('cjpeg')
cmd = [exe] + '-optimize -progressive -maxmemory 100M -quality'.split() + [str(quality)]
img = QImage()
if not img.load(file_path):
raise ValueError('%s is not a valid image file' % file_path)
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.WriteOnly)
if not img.save(buf, 'PPM'):
raise ValueError('Failed to export image to PPM')
data = ReadOnlyFileBuffer(ba.data())
buf.close()
return run_optimizer(file_path, cmd, as_filter=True, input_data=data)
def encode_webp(file_path, quality=75, m=6, metadata='all'):
return run_cwebp(file_path, False, quality, m, metadata)
# }}}
# PIL images {{{
def align8to32(bytes, width, mode):
"""
converts each scanline of data from 8 bit to 32 bit aligned
"""
bits_per_pixel = {"1": 1, "L": 8, "P": 8, "I;16": 16}[mode]
# calculate bytes per line and the extra padding if needed
bits_per_line = bits_per_pixel * width
full_bytes_per_line, remaining_bits_per_line = divmod(bits_per_line, 8)
bytes_per_line = full_bytes_per_line + (1 if remaining_bits_per_line else 0)
extra_padding = -bytes_per_line % 4
# already 32 bit aligned by luck
if not extra_padding:
return bytes
new_data = [
bytes[i * bytes_per_line : (i + 1) * bytes_per_line] + b"\x00" * extra_padding
for i in range(len(bytes) // bytes_per_line)
]
return b"".join(new_data)
def convert_PIL_image_to_pixmap(im, device_pixel_ratio=1.0):
data = None
colortable = None
if im.mode == "RGBA":
fmt = QImage.Format.Format_RGBA8888
data = im.tobytes("raw", "RGBA")
elif im.mode == "1":
fmt = QImage.Format.Format_Mono
elif im.mode == "L":
fmt = QImage.Format.Format_Indexed8
colortable = [qRgba(i, i, i, 255) & 0xFFFFFFFF for i in range(256)]
elif im.mode == "P":
fmt = QImage.Format.Format_Indexed8
palette = im.getpalette()
colortable = [qRgba(*palette[i : i + 3], 255) & 0xFFFFFFFF for i in range(0, len(palette), 3)]
elif im.mode == "I;16":
im = im.point(lambda i: i * 256)
fmt = QImage.Format.Format_Grayscale16
else:
fmt = QImage.Format.Format_RGBX8888
data = im.convert("RGBA").tobytes("raw", "RGBA")
size = im.size
data = data or align8to32(im.tobytes(), size[0], im.mode)
qimg = QImage(data, size[0], size[1], fmt)
if device_pixel_ratio != 1.0:
qimg.setDevicePixelRatio(device_pixel_ratio)
if colortable:
qimg.setColorTable(colortable)
return QPixmap.fromImage(qimg)
# }}}
def test(): # {{{
from glob import glob
from calibre import CurrentDir
from calibre.ptempfile import TemporaryDirectory
img = image_from_data(I('lt.png', data=True, allow_user_override=False))
with TemporaryDirectory() as tdir, CurrentDir(tdir):
save_image(img, 'test.jpg')
ret = optimize_jpeg('test.jpg')
if ret is not None:
raise SystemExit('optimize_jpeg failed: %s' % ret)
ret = encode_jpeg('test.jpg')
if ret is not None:
raise SystemExit('encode_jpeg failed: %s' % ret)
shutil.copyfile(I('lt.png'), 'test.png')
ret = optimize_png('test.png')
if ret is not None:
raise SystemExit('optimize_png failed: %s' % ret)
if glob('*.bak'):
raise SystemExit('Spurious .bak files left behind')
save_image(img, 'test.webp', compression_quality=100)
ret = optimize_webp('test.webp')
if ret is not None:
raise SystemExit('optimize_webp failed: %s' % ret)
quantize_image(img)
oil_paint_image(img)
gaussian_sharpen_image(img)
gaussian_blur_image(img)
despeckle_image(img)
remove_borders_from_image(img)
image_to_data(img, fmt='GIF')
p = subprocess.Popen([get_exe_path('JxrDecApp'), '-h'],
creationflags=subprocess.DETACHED_PROCESS if iswindows else 0,
stdout=subprocess.PIPE)
raw, _ = p.communicate()
p.wait()
if b'JPEG XR Decoder Utility' not in raw:
raise SystemExit('Failed to run JxrDecApp')
# }}}
if __name__ == '__main__': # {{{
args = sys.argv[1:]
infile = args.pop(0)
img = image_from_data(open(infile, 'rb').read())
func = globals()[args[0]]
kw = {}
args.pop(0)
outf = None
while args:
k = args.pop(0)
if '=' in k:
n, v = k.partition('=')[::2]
if v in ('True', 'False'):
v = True if v == 'True' else False
try:
v = int(v)
except Exception:
try:
v = float(v)
except Exception:
pass
kw[n] = v
else:
outf = k
if outf is None:
bn = os.path.basename(infile)
outf = bn.rpartition('.')[0] + '.' + '-output' + bn.rpartition('.')[-1]
img = func(img, **kw)
with open(outf, 'wb') as f:
f.write(image_to_data(img, fmt=outf.rpartition('.')[-1]))
# }}}