Kobo driver: Add options to more precisely control the generation of cover images, to reduce size and enhance quality

Merge branch 'master' of https://github.com/NiLuJe/calibre
This commit is contained in:
Kovid Goyal
2019-06-11 15:14:26 +05:30
8 changed files with 541 additions and 96 deletions
+169 -77
View File
@@ -3,11 +3,11 @@
from __future__ import absolute_import, division, print_function, unicode_literals
__license__ = 'GPL v3'
__copyright__ = '2010-2012, Timothy Legge <timlegge@gmail.com>, Kovid Goyal <kovid@kovidgoyal.net> and David Forrester <davidfor@internode.on.net>'
__copyright__ = '2010-2019, Timothy Legge <timlegge@gmail.com>, Kovid Goyal <kovid@kovidgoyal.net> and David Forrester <davidfor@internode.on.net>'
__docformat__ = 'restructuredtext en'
'''
Driver for Kobo ereaders. Supports all e-ink devices.
Driver for Kobo eReaders. Supports all e-ink devices.
Originally developed by Timothy Legge <timlegge@gmail.com>.
Extended to support Touch firmware 2.0.0 and later and newer devices by David Forrester <davidfor@internode.on.net>
@@ -30,7 +30,7 @@ from calibre.devices.kobo.books import ImageWrapper
from calibre.devices.mime import mime_type_ext
from calibre.devices.usbms.driver import USBMS, debug_print
from calibre import prints, fsync
from calibre.ptempfile import PersistentTemporaryFile
from calibre.ptempfile import PersistentTemporaryFile, better_mktemp
from calibre.constants import DEBUG
from calibre.utils.config_base import prefs
from polyglot.builtins import iteritems, itervalues, unicode_type, string_or_bytes
@@ -1052,9 +1052,9 @@ class KOBO(USBMS):
with lopen(cover, 'rb') as f:
data = f.read()
# Return the data resized and in Grayscale if
# Return the data resized and grayscaled if
# required
data = save_cover_data_to(data, grayscale=uploadgrayscale, resize_to=resize)
data = save_cover_data_to(data, grayscale=uploadgrayscale, resize_to=resize, minify_to=resize)
with lopen(fpath, 'wb') as f:
f.write(data)
@@ -1340,12 +1340,12 @@ class KOBO(USBMS):
class KOBOTOUCH(KOBO):
name = 'KoboTouch'
gui_name = 'Kobo Touch/Glo/Mini/Aura HD/Aura H2O/Glo HD/Touch 2'
gui_name = 'Kobo eReader'
author = 'David Forrester'
description = _(
'Communicate with the Kobo Touch, Glo, Mini, Aura HD,'
' Aura H2O, Glo HD, Touch 2, Aura ONE, Aura Edition 2,'
' Aura H2O Edition 2, Clara HD and Forma ereaders.'
' Aura H2O Edition 2, Clara HD and Forma eReaders.'
' Based on the existing Kobo driver by %s.') % KOBO.author
# icon = I('devices/kobotouch.jpg')
@@ -1416,71 +1416,70 @@ class KOBOTOUCH(KOBO):
# Image file name endings. Made up of: image size, min_dbversion, max_dbversion, isFullSize,
# Note: "200" has been used just as a much larger number than the current versions. It is just a lazy
# way of making it open ended.
COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
' - N3_FULL.parsed':[(600,800),0, 200,True,], # Used for screensaver, home screen
# NOTE: Values pulled from Nickel by @geek1011,
# c.f., this handy recap: https://github.com/shermp/Kobo-UNCaGED/issues/16#issuecomment-494229994
# Only the N3_FULL values differ, as they should match the screen's effective resolution.
# Note that all Kobo devices share a common AR at roughly 0.75,
# so results should be similar, no matter the exact device.
# Common to all Kobo models
COMMON_COVER_FILE_ENDINGS = {
# Used for Details screen before FW2.8.1, then for current book tile on home screen
' - N3_LIBRARY_FULL.parsed':[(355,473),0, 200,False,],
' - N3_LIBRARY_FULL.parsed': [(355,530),0, 200,False,],
# Used for library lists
' - N3_LIBRARY_GRID.parsed':[(149,198),0, 200,False,], # Used for library lists
' - N3_LIBRARY_GRID.parsed': [(149,223),0, 200,False,],
# Used for library lists
' - N3_LIBRARY_LIST.parsed':[(60,90),0, 53,False,],
' - N3_LIBRARY_LIST.parsed': [(60,90),0, 53,False,],
# Used for Details screen from FW2.8.1
' - AndroidBookLoadTablet_Aspect.parsed':[(355,473), 82, 100,False,],
' - AndroidBookLoadTablet_Aspect.parsed': [(355,530), 82, 100,False,],
}
# Glo and Aura share resolution, so the image sizes should be the same.
# Legacy 6" devices
LEGACY_COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
' - N3_FULL.parsed': [(600,800),0, 200,True,],
}
# Glo
GLO_COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
' - N3_FULL.parsed':[(758,1024),0, 200,True,],
# Used for Details screen before FW2.8.1, then for current book tile on home screen
' - N3_LIBRARY_FULL.parsed':[(355,479),0, 200,False,],
# Used for library lists
' - N3_LIBRARY_GRID.parsed':[(149,201),0, 200,False,],
# Used for Details screen from FW2.8.1
' - AndroidBookLoadTablet_Aspect.parsed':[(355,479), 88, 100,False,],
' - N3_FULL.parsed': [(758,1024),0, 200,True,],
}
# Aura
AURA_COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
# NOTE: The Aura's bezel covers 10 pixels at the bottom.
# Kobo officially advertised the screen resolution with those chopped off.
' - N3_FULL.parsed': [(758,1014),0, 200,True,],
}
# Glo HD and Clara HD share resolution, so the image sizes should be the same.
GLO_HD_COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
' - N3_FULL.parsed': [(1072,1448), 0, 200,True,],
# Used for Details screen before FW2.8.1, then for current book tile on home screen
' - N3_LIBRARY_FULL.parsed':[(355, 479), 0, 200,False,],
# Used for library lists
' - N3_LIBRARY_GRID.parsed':[(149, 201), 0, 200,False,],
# Used for Details screen from FW2.8.1
' - AndroidBookLoadTablet_Aspect.parsed':[(355, 471), 88, 100,False,],
}
AURA_HD_COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
' - N3_FULL.parsed': [(1080,1440), 0, 200,True,],
# Used for Details screen before FW2.8.1, then for current book tile on home screen
' - N3_LIBRARY_FULL.parsed':[(355, 471), 0, 200,False,],
# Used for library lists
' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,],
# Used for Details screen from FW2.8.1
' - AndroidBookLoadTablet_Aspect.parsed':[(355, 471), 88, 100,False,],
}
AURA_H2O_COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
# NOTE: The H2O's bezel covers 11 pixels at the top.
# Unlike on the Aura, Nickel fails to account for this when generating covers.
# c.f., https://github.com/shermp/Kobo-UNCaGED/pull/17#discussion_r286209827
' - N3_FULL.parsed': [(1080,1429), 0, 200,True,],
}
AURA_ONE_COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
' - N3_FULL.parsed': [(1404,1872), 0, 200,True,],
# Used for Details screen before FW2.8.1, then for current book tile on home screen
' - N3_LIBRARY_FULL.parsed':[(355, 473), 0, 200,False,],
# Used for library lists
' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,], # Used for library lists
}
FORMA_COVER_FILE_ENDINGS = {
# Used for screensaver, home screen
# NOTE: Nickel currently fails to honor the real screen resolution when generating covers,
# choosing instead to follow the Aura One codepath.
' - N3_FULL.parsed': [(1440,1920), 0, 200,True,],
# Used for Details screen before FW2.8.1, then for current book tile on home screen
' - N3_LIBRARY_FULL.parsed':[(355, 473), 0, 200,False,],
# Used for library lists
' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,], # Used for library lists
}
# Following are the sizes used with pre2.1.4 firmware
# COVER_FILE_ENDINGS = {
# ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 99,], # Used for Details screen
# ' - N3_LIBRARY_FULL.parsed':[(600,800),0, 99,],
# ' - N3_LIBRARY_GRID.parsed':[(149,233),0, 99,], # Used for library lists
# ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 99,], # Used for library lists
# ' - N3_LIBRARY_LIST.parsed':[(60,90),0, 53,],
# ' - N3_LIBRARY_SHELF.parsed': [(40,60),0, 52,],
# ' - N3_FULL.parsed':[(600,800),0, 99,], # Used for screensaver if "Full screen" is checked.
@@ -1507,7 +1506,7 @@ class KOBOTOUCH(KOBO):
# Just dump some info to the logs.
super(KOBOTOUCH, self).open_osx()
# Wrap some debugging output in a try/except so that it unlikely to break things completely.
# Wrap some debugging output in a try/except so that it is unlikely to break things completely.
try:
if DEBUG:
from calibre.constants import plugins
@@ -2560,7 +2559,10 @@ class KOBOTOUCH(KOBO):
# debug_print('KoboTouch: uploading cover')
try:
self._upload_cover(path, filename, metadata, filepath, self.upload_grayscale, self.keep_cover_aspect)
self._upload_cover(
path, filename, metadata, filepath,
self.upload_grayscale, self.dithered_covers,
self.keep_cover_aspect, self.letterbox_fs_covers, self.png_covers)
except Exception as e:
debug_print('KoboTouch: FAILED to upload cover=%s Exception=%s'%(filepath, unicode_type(e)))
@@ -2589,17 +2591,36 @@ class KOBOTOUCH(KOBO):
path = os.path.join(path, imageId)
return path
def _calculate_kobo_cover_size(self, library_size, kobo_size, keep_cover_aspect, is_full_size):
if keep_cover_aspect:
library_aspect = library_size[0] / library_size[1]
kobo_aspect = kobo_size[0] / kobo_size[1]
if library_aspect > kobo_aspect:
kobo_size = (kobo_size[0], int(kobo_size[0] / library_aspect))
else:
kobo_size = (int(library_aspect * kobo_size[1]), kobo_size[1])
return kobo_size
def _calculate_kobo_cover_size(self, library_size, kobo_size, expand, keep_cover_aspect, letterbox):
# Remember the canvas size
canvas_size = kobo_size
def _create_cover_data(self, cover_data, resize_to, kobo_size, upload_grayscale=False, keep_cover_aspect=False, is_full_size=False):
# NOTE: Loosely based on Qt's QSize::scaled implementation
if keep_cover_aspect:
# NOTE: Unlike Qt, we round to avoid accumulating errors,
# as ImageOps will then floor via fit_image
aspect_ratio = library_size[0] / library_size[1]
rescaled_width = int(round(kobo_size[1] * aspect_ratio))
if expand:
use_height = (rescaled_width >= kobo_size[0])
else:
use_height = (rescaled_width <= kobo_size[0])
if use_height:
kobo_size = (rescaled_width, kobo_size[1])
else:
kobo_size = (kobo_size[0], int(round(kobo_size[0] / aspect_ratio)))
# Did we actually want to letterbox?
if not letterbox:
canvas_size = kobo_size
return (kobo_size, canvas_size)
def _create_cover_data(
self, cover_data, resize_to, minify_to, kobo_size,
upload_grayscale=False, dithered_covers=False, keep_cover_aspect=False, is_full_size=False, letterbox=False, png_covers=False, quality=90
):
'''
This will generate the new cover image from the cover in the library. It is a wrapper
for save_cover_data_to to allow it to be overriden in a subclass. For this reason,
@@ -2607,23 +2628,32 @@ class KOBOTOUCH(KOBO):
:param cover_data: original cover data
:param resize_to: Size to resize the cover to (width, height). None means do not resize.
:param minify_to: Maximum canvas size for the resized cover (width, height).
:param kobo_size: Size of the cover image on the device.
:param upload_grayscale: boolean True if driver configured to send grayscale thumbnails
Passed to allow ability to decide to quantize to 16-col grayscale
at calibre end
:param keep_cover_aspect: bookean - True if the aspect ratio of the cover in the library is to be kept.
:param dithered_covers: boolean True if driver configured to quantize to 16-col grayscale
:param keep_cover_aspect: boolean - True if the aspect ratio of the cover in the library is to be kept.
:param is_full_size: True if this is the kobo_size is for the full size cover image
Passed to allow ability to process screensaver differently
to smaller thumbnails
:param letterbox: True if we were asked to handle the letterboxing
:param png_covers: True if we were asked to encode those images in PNG instead of JPG
:param quality: 0-100 Output encoding quality (or compression level for PNG, àla IM)
'''
from calibre.utils.img import save_cover_data_to
data = save_cover_data_to(cover_data, grayscale=upload_grayscale, resize_to=resize_to)
data = save_cover_data_to(
cover_data, resize_to=resize_to, compression_quality=quality, minify_to=minify_to, grayscale=upload_grayscale, eink=dithered_covers,
letterbox=letterbox, data_fmt="png" if png_covers else "jpeg")
return data
def _upload_cover(self, path, filename, metadata, filepath, upload_grayscale, keep_cover_aspect=False):
def _upload_cover(
self, path, filename, metadata, filepath, upload_grayscale,
dithered_covers=False, keep_cover_aspect=False, letterbox_fs_covers=False, png_covers=False
):
from calibre.utils.imghdr import identify
debug_print("KoboTouch:_upload_cover - filename='%s' upload_grayscale='%s' "%(filename, upload_grayscale))
from calibre.utils.img import optimize_png
debug_print("KoboTouch:_upload_cover - filename='%s' upload_grayscale='%s' dithered_covers='%s' "%(filename, upload_grayscale, dithered_covers))
if not metadata.cover:
return
@@ -2666,7 +2696,7 @@ class KOBOTOUCH(KOBO):
image_dir = os.path.dirname(os.path.abspath(path))
if not os.path.exists(image_dir):
debug_print("KoboTouch:_upload_cover - Image directory does not exust. Creating path='%s'" % (image_dir))
debug_print("KoboTouch:_upload_cover - Image directory does not exist. Creating path='%s'" % (image_dir))
os.makedirs(image_dir)
with lopen(cover, 'rb') as f:
@@ -2678,8 +2708,8 @@ class KOBOTOUCH(KOBO):
for ending, cover_options in self.cover_file_endings().items():
kobo_size, min_dbversion, max_dbversion, is_full_size = cover_options
if show_debug:
debug_print("KoboTouch:_upload_cover - library_cover_size=%s min_dbversion=%d max_dbversion=%d, is_full_size=%s" % (
library_cover_size, min_dbversion, max_dbversion, is_full_size))
debug_print("KoboTouch:_upload_cover - library_cover_size=%s -> kobo_size=%s, min_dbversion=%d max_dbversion=%d, is_full_size=%s" % (
library_cover_size, kobo_size, min_dbversion, max_dbversion, is_full_size))
if self.dbversion >= min_dbversion and self.dbversion <= max_dbversion:
if show_debug:
@@ -2687,14 +2717,58 @@ class KOBOTOUCH(KOBO):
fpath = path + ending
fpath = self.normalize_path(fpath.replace('/', os.sep))
resize_to = self._calculate_kobo_cover_size(library_cover_size, kobo_size, keep_cover_aspect, is_full_size)
# Never letterbox thumbnails, that's ugly. But for fullscreen covers, honor the setting.
letterbox = letterbox_fs_covers and is_full_size
# Return the data resized and in Grayscale if required
data = self._create_cover_data(cover_data, resize_to, kobo_size, upload_grayscale, keep_cover_aspect, is_full_size)
# NOTE: Full size means we have to fit *inside* the
# given boundaries. Thumbnails, on the other hand, are
# *expanded* around those boundaries.
# In Qt, it'd mean full-screen covers are resized
# using Qt::KeepAspectRatio, while thumbnails are
# resized using Qt::KeepAspectRatioByExpanding
# (i.e., QSize's boundedTo() vs. expandedTo(). See also IM's '^' geometry token, for the same "expand" behavior.)
# Note that Nickel itself will generate bounded thumbnails, while it will download expanded thumbnails for store-bought KePubs...
# We chose to emulate the KePub behavior.
resize_to, expand_to = self._calculate_kobo_cover_size(library_cover_size, kobo_size, not is_full_size, keep_cover_aspect, letterbox)
if show_debug:
debug_print(
"KoboTouch:_calculate_kobo_cover_size - expand_to=%s"
" (vs. kobo_size=%s) & resize_to=%s, keep_cover_aspect=%s & letterbox_fs_covers=%s, png_covers=%s" % (
expand_to, kobo_size, resize_to, keep_cover_aspect, letterbox_fs_covers, png_covers))
with lopen(fpath, 'wb') as f:
f.write(data)
fsync(f)
# NOTE: To speed things up, we enforce a lower
# compression level for png_covers, as the final
# optipng pass will then select a higher compression
# level anyway,
# so the compression level from that first pass
# is irrelevant, and only takes up precious time
# ;).
quality = 10 if png_covers else 90
# Return the data resized and properly grayscaled/dithered/letterboxed if requested
data = self._create_cover_data(
cover_data, resize_to, expand_to, kobo_size, upload_grayscale,
dithered_covers, keep_cover_aspect, is_full_size, letterbox, png_covers, quality)
# NOTE: If we're writing a PNG file, go through a quick
# optipng pass to make sure it's encoded properly, as
# Qt doesn't afford us enough control to do it right...
# Unfortunately, optipng doesn't support reading
# pipes, so this gets a bit clunky as we have go
# through a temporary file...
if png_covers:
tmp_cover = better_mktemp()
with lopen(tmp_cover, 'wb') as f:
f.write(data)
optimize_png(tmp_cover, level=1)
# Crossing FS boundaries, can't rename, have to copy + delete :/
shutil.copy2(tmp_cover, fpath)
os.remove(tmp_cover)
else:
with lopen(fpath, 'wb') as f:
f.write(data)
fsync(f)
except Exception as e:
err = unicode_type(e)
debug_print("KoboTouch:_upload_cover - Exception string: %s"%err)
@@ -3172,8 +3246,11 @@ class KOBOTOUCH(KOBO):
c.add_opt('ignore_collections_names', default='')
c.add_opt('upload_covers', default=False)
c.add_opt('dithered_covers', default=False)
c.add_opt('keep_cover_aspect', default=False)
c.add_opt('upload_grayscale', default=False)
c.add_opt('letterbox_fs_covers', default=False)
c.add_opt('png_covers', default=False)
c.add_opt('show_archived_books', default=False)
c.add_opt('show_previews', default=False)
@@ -3245,13 +3322,13 @@ class KOBOTOUCH(KOBO):
def cover_file_endings(self):
if self.isAura():
_cover_file_endings = self.GLO_COVER_FILE_ENDINGS
_cover_file_endings = self.AURA_COVER_FILE_ENDINGS
elif self.isAuraEdition2():
_cover_file_endings = self.GLO_COVER_FILE_ENDINGS
elif self.isAuraHD():
_cover_file_endings = self.AURA_HD_COVER_FILE_ENDINGS
elif self.isAuraH2O():
_cover_file_endings = self.AURA_HD_COVER_FILE_ENDINGS
_cover_file_endings = self.AURA_H2O_COVER_FILE_ENDINGS
elif self.isAuraH2OEdition2():
_cover_file_endings = self.AURA_HD_COVER_FILE_ENDINGS
elif self.isAuraOne():
@@ -3265,15 +3342,18 @@ class KOBOTOUCH(KOBO):
elif self.isGloHD():
_cover_file_endings = self.GLO_HD_COVER_FILE_ENDINGS
elif self.isMini():
_cover_file_endings = self.COVER_FILE_ENDINGS
_cover_file_endings = self.LEGACY_COVER_FILE_ENDINGS
elif self.isTouch():
_cover_file_endings = self.COVER_FILE_ENDINGS
_cover_file_endings = self.LEGACY_COVER_FILE_ENDINGS
elif self.isTouch2():
_cover_file_endings = self.COVER_FILE_ENDINGS
_cover_file_endings = self.LEGACY_COVER_FILE_ENDINGS
else:
_cover_file_endings = self.COVER_FILE_ENDINGS
_cover_file_endings = self.LEGACY_COVER_FILE_ENDINGS
return _cover_file_endings
# Don't forget to merge that on top of the common dictionary (c.f., https://stackoverflow.com/q/38987)
_all_cover_file_endings = self.COMMON_COVER_FILE_ENDINGS.copy()
_all_cover_file_endings.update(_cover_file_endings)
return _all_cover_file_endings
def set_device_name(self):
device_name = self.gui_name
@@ -3357,6 +3437,18 @@ class KOBOTOUCH(KOBO):
def upload_grayscale(self):
return self.upload_covers and self.get_pref('upload_grayscale')
@property
def dithered_covers(self):
return self.upload_grayscale and self.get_pref('dithered_covers')
@property
def letterbox_fs_covers(self):
return self.keep_cover_aspect and self.get_pref('letterbox_fs_covers')
@property
def png_covers(self):
return self.upload_grayscale and self.get_pref('png_covers')
def modifying_epub(self):
return self.modifying_css()
+67 -5
View File
@@ -3,12 +3,12 @@
from __future__ import absolute_import, division, print_function, unicode_literals
__license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
__copyright__ = '2015-2019, Kovid Goyal <kovid at kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import textwrap
from PyQt5.Qt import (QWidget, QLabel, QGridLayout, QLineEdit, QVBoxLayout,
from PyQt5.Qt import (Qt, QWidget, QLabel, QGridLayout, QLineEdit, QVBoxLayout,
QDialog, QDialogButtonBox, QCheckBox, QPushButton)
from calibre.gui2.device_drivers.tabbed_device_config import TabbedDeviceConfig, DeviceConfigTab, DeviceOptionsGroupBox
@@ -106,6 +106,9 @@ class KOBOTOUCHConfig(TabbedDeviceConfig):
p['upload_covers'] = self.upload_covers
p['keep_cover_aspect'] = self.keep_cover_aspect
p['upload_grayscale'] = self.upload_grayscale
p['dithered_covers'] = self.dithered_covers
p['letterbox_fs_covers'] = self.letterbox_fs_covers
p['png_covers'] = self.png_covers
p['show_recommendations'] = self.show_recommendations
p['show_previews'] = self.show_previews
@@ -305,18 +308,65 @@ class CoversGroupBox(DeviceOptionsGroupBox):
self.upload_grayscale_checkbox = create_checkbox(
_('Upload black and white covers'),
_('Convert covers to black and white when uploading'),
_('Convert covers to grayscale when uploading.'),
device.get_pref('upload_grayscale')
)
self.dithered_covers_checkbox = create_checkbox(
_('Upload dithered covers'),
_('Dither cover images to the appropriate 16c grayscale palette for an eInk screen.'
' This usually ensures greater accuracy and avoids banding, making sleep covers look better.'
' On FW >= 4.11, Nickel itself may sometimes do a decent job of it.'
' Has no effect without "Upload black and white covers"!'),
device.get_pref('dithered_covers')
)
# Make it visually depend on B&W being enabled!
# c.f., https://stackoverflow.com/q/36281103
self.dithered_covers_checkbox.setEnabled(device.get_pref('upload_grayscale'))
self.upload_grayscale_checkbox.toggled.connect(self.dithered_covers_checkbox.setEnabled)
self.upload_grayscale_checkbox.toggled.connect(
lambda checked: not checked and self.dithered_covers_checkbox.setChecked(False))
self.keep_cover_aspect_checkbox = create_checkbox(
_('Keep cover aspect ratio'),
_('When uploading covers, do not change the aspect ratio when resizing for the device.'
' This is for firmware versions 2.3.1 and later.'),
device.get_pref('keep_cover_aspect'))
self.options_layout.addWidget(self.keep_cover_aspect_checkbox, 0, 0, 1, 1)
self.options_layout.addWidget(self.upload_grayscale_checkbox, 1, 0, 1, 1)
self.letterbox_fs_covers_checkbox = create_checkbox(
_('Letterbox full-screen covers'),
_('Do it on our end, instead of letting Nickel handle it.'
' Provides pixel-perfect results on devices where Nickel does not do extra processing.'
' Obviously has no effect without "Keep cover aspect ratio".'
' This is probably undesirable if you disable the "Show book covers full screen"'
' setting on your device.'),
device.get_pref('letterbox_fs_covers'))
# Make it visually depend on AR being enabled!
self.letterbox_fs_covers_checkbox.setEnabled(device.get_pref('keep_cover_aspect'))
self.keep_cover_aspect_checkbox.toggled.connect(self.letterbox_fs_covers_checkbox.setEnabled)
self.keep_cover_aspect_checkbox.toggled.connect(
lambda checked: not checked and self.letterbox_fs_covers_checkbox.setChecked(False))
self.png_covers_checkbox = create_checkbox(
_('Save covers as PNG'),
_('Use the PNG image format instead of JPG.'
' Higher quality, especially with "Upload dithered covers" enabled,'
' which will also help generate potentially smaller files.'
' Behavior completely unknown on old (< 3.x) Kobo firmwares,'
' known to behave on FW >= 4.8.'
' Has no effect without "Upload black and white covers"!'),
device.get_pref('png_covers'))
# Make it visually depend on B&W being enabled, to avoid storing ridiculously large color PNGs.
self.png_covers_checkbox.setEnabled(device.get_pref('upload_grayscale'))
self.upload_grayscale_checkbox.toggled.connect(self.png_covers_checkbox.setEnabled)
self.upload_grayscale_checkbox.toggled.connect(
lambda checked: not checked and self.png_covers_checkbox.setChecked(False))
self.options_layout.addWidget(self.upload_grayscale_checkbox, 0, 0, 1, 1)
self.options_layout.addWidget(self.dithered_covers_checkbox, 0, 1, 1, 1)
self.options_layout.addWidget(self.keep_cover_aspect_checkbox, 1, 0, 1, 1)
self.options_layout.addWidget(self.letterbox_fs_covers_checkbox, 1, 1, 1, 1)
self.options_layout.addWidget(self.png_covers_checkbox, 2, 0, 1, 2, Qt.AlignCenter)
@property
def upload_covers(self):
@@ -326,10 +376,22 @@ class CoversGroupBox(DeviceOptionsGroupBox):
def upload_grayscale(self):
return self.upload_grayscale_checkbox.isChecked()
@property
def dithered_covers(self):
return self.dithered_covers_checkbox.isChecked()
@property
def keep_cover_aspect(self):
return self.keep_cover_aspect_checkbox.isChecked()
@property
def letterbox_fs_covers(self):
return self.letterbox_fs_covers_checkbox.isChecked()
@property
def png_covers(self):
return self.png_covers_checkbox.isChecked()
class DeviceListGroupBox(DeviceOptionsGroupBox):
+1
View File
@@ -22,6 +22,7 @@ QImage quantize(const QImage &image, unsigned int maximum_colors, bool dither, c
bool has_transparent_pixels(const QImage &image);
QImage set_opacity(const QImage &image, double alpha);
QImage texture_image(const QImage &image, const QImage &texturei);
QImage ordered_dither(const QImage &image);
class ScopedGILRelease {
public:
+8 -1
View File
@@ -15,7 +15,7 @@
} catch (std::out_of_range &exc) { PyErr_SetString(PyExc_ValueError, exc.what()); return NULL; \
} catch (std::bad_alloc &) { PyErr_NoMemory(); return NULL; \
} catch (std::exception &exc) { PyErr_SetString(PyExc_Exception, exc.what()); return NULL; \
} catch (...) { PyErr_SetString(PyExc_RuntimeError, "unknown error"); return NULL;}
} catch (...) { PyErr_SetString(PyExc_RuntimeError, "unknown error"); return NULL;}
%End
QImage* remove_borders(const QImage &image, double fuzz);
@@ -100,3 +100,10 @@ QImage texture_image(const QImage &image, const QImage &texturei);
sipRes = new QImage(texture_image(*a0, *a1));
IMAGEOPS_SUFFIX
%End
QImage ordered_dither(const QImage &image);
%MethodCode
IMAGEOPS_PREFIX
sipRes = new QImage(ordered_dither(*a0));
IMAGEOPS_SUFFIX
%End
@@ -0,0 +1,130 @@
/*
* ordered_dither.cpp
* Glue code based on quantize.cpp, Copyright (C) 2016 Kovid Goyal <kovid at kovidgoyal.net>
* Actual ordered dithering routine (dither_o8x8) is Copyright 1999-2019 ImageMagick Studio LLC,
*
* Licensed under the ImageMagick License (the "License"); you may not use
* this file except in compliance with the License. You may obtain a copy
* of the License at
*
* https://imagemagick.org/script/license.php
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
#include "imageops.h"
// Just in case, as I don't want to deal with MSVC madness...
#if defined _MSC_VER && _MSC_VER < 1700
typedef unsigned __int8 uint8_t;
#define UINT8_MAX _UI8_MAX
typedef unsigned __int32 uint32_t;
#else
#include <stdint.h>
#endif
// Only needed for the (commented out) Indexed8 codepath
//#include <QVector>
// NOTE: *May* not behave any better than a simple / 0xFF on modern x86_64 CPUs...
// This was, however, tested on ARM, where it is noticeably faster.
static uint32_t DIV255(uint32_t v) {
v += 128;
return (((v >> 8U) + v) >> 8U);
}
// Quantize an 8-bit color value down to a palette of 16 evenly spaced colors, using an ordered 8x8 dithering pattern.
// With a grayscale input, this happens to match the eInk palette perfectly ;).
// If the input is not grayscale, and the output fb is not grayscale either,
// this usually still happens to match the eInk palette after the EPDC's own quantization pass.
// c.f., https://en.wikipedia.org/wiki/Ordered_dithering
// & https://github.com/ImageMagick/ImageMagick/blob/ecfeac404e75f304004f0566557848c53030bad6/MagickCore/threshold.c#L1627
// NOTE: As the references imply, this is straight from ImageMagick,
// with only minor simplifications to enforce Q8 & avoid fp maths.
static uint8_t
dither_o8x8(int x, int y, uint8_t v)
{
// c.f., https://github.com/ImageMagick/ImageMagick/blob/ecfeac404e75f304004f0566557848c53030bad6/config/thresholds.xml#L107
static const uint8_t threshold_map_o8x8[] = { 1, 49, 13, 61, 4, 52, 16, 64, 33, 17, 45, 29, 36, 20, 48, 32,
9, 57, 5, 53, 12, 60, 8, 56, 41, 25, 37, 21, 44, 28, 40, 24,
3, 51, 15, 63, 2, 50, 14, 62, 35, 19, 47, 31, 34, 18, 46, 30,
11, 59, 7, 55, 10, 58, 6, 54, 43, 27, 39, 23, 42, 26, 38, 22 };
// Constants:
// Quantum = 8; Levels = 16; map Divisor = 65
// QuantumRange = 0xFF
// QuantumScale = 1.0 / QuantumRange
//
// threshold = QuantumScale * v * ((L-1) * (D-1) + 1)
// NOTE: The initial computation of t (specifically, what we pass to DIV255) would overflow an uint8_t.
// With a Q8 input value, we're at no risk of ever underflowing, so, keep to unsigned maths.
// Technically, an uint16_t would be wide enough, but it gains us nothing,
// and requires a few explicit casts to make GCC happy ;).
uint32_t t = DIV255(v * ((15U << 6) + 1U));
// level = t / (D-1);
uint32_t l = (t >> 6);
// t -= l * (D-1);
t = (t - (l << 6));
// map width & height = 8
// c = ClampToQuantum((l+(t >= map[(x % mw) + mw * (y % mh)])) * QuantumRange / (L-1));
uint32_t q = ((l + (t >= threshold_map_o8x8[(x & 7U) + 8U * (y & 7U)])) * 17);
// NOTE: We're doing unsigned maths, so, clamping is basically MIN(q, UINT8_MAX) ;).
// The only overflow we should ever catch should be for a few white (v = 0xFF) input pixels
// that get shifted to the next step (i.e., q = 272 (0xFF + 17)).
return (q > UINT8_MAX ? UINT8_MAX : static_cast<uint8_t>(q));
}
QImage ordered_dither(const QImage &image) { // {{{
ScopedGILRelease PyGILRelease;
QImage img = image;
int y = 0, x = 0, width = img.width(), height = img.height();
uint8_t gray = 0, dithered = 0;
// NOTE: We went with Grayscale8 because QImageWriter was doing some weird things with an Indexed8 input...
QImage dst(width, height, QImage::Format_Grayscale8);
/*
QImage dst(width, height, QImage::Format_Indexed8);
// Set up the eInk palette
// FIXME: Make it const and switch to C++11 list init if MSVC is amenable...
QVector<uint8_t> palette(16);
QVector<QRgb> color_table(16);
int i = 0;
for (i = 0; i < 16; i++) {
uint8_t color = i * 17;
palette << color;
color_table << qRgb(color, color, color);
}
dst.setColorTable(color_table);
*/
// We're running behind blend_image, so, we should only ever be fed RGB32 as input...
if (img.format() != QImage::Format_RGB32) {
img = img.convertToFormat(QImage::Format_RGB32);
if (img.isNull()) throw std::bad_alloc();
}
const bool is_gray = img.isGrayscale();
for (y = 0; y < height; y++) {
const QRgb *src_row = reinterpret_cast<const QRgb*>(img.constScanLine(y));
uint8_t *dst_row = dst.scanLine(y);
for (x = 0; x < width; x++) {
const QRgb pixel = *(src_row + x);
if (is_gray) {
// Grayscale and RGB32, so R = G = B
gray = qRed(pixel);
} else {
gray = qGray(pixel);
}
dithered = dither_o8x8(x, y, gray);
*(dst_row + x) = dithered; // ... or palette.indexOf(dithered); for Indexed8
}
}
return dst;
} // }}}
+55 -12
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python2
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2015, Kovid Goyal <kovid at kovidgoyal.net>
# License: GPLv3 Copyright: 2015-2019, Kovid Goyal <kovid at kovidgoyal.net>
from __future__ import absolute_import, division, print_function, unicode_literals
@@ -175,7 +175,16 @@ def save_image(img, path, **kw):
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, data_fmt='jpeg'):
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,
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
@@ -187,12 +196,20 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr
: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).
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).
'''
fmt = normalize_format_name(data_fmt if path is None else os.path.splitext(path)[1][1:])
if isinstance(data, QImage):
@@ -207,21 +224,32 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr
img = img.scaled(resize_to[0], resize_to[1], Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
owidth, oheight = img.width(), img.height()
nwidth, nheight = tweaks['maximum_cover_size'] if minify_to is None else minify_to
scaled, nwidth, nheight = fit_image(owidth, oheight, nwidth, nheight)
if scaled:
changed = True
img = img.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
if letterbox:
img = blend_on_canvas(img, nwidth, nheight, bgcolor='#000000')
# 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(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
if img.hasAlphaChannel():
changed = True
img = blend_image(img, bgcolor)
if grayscale:
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) if changed else data
return image_to_data(img, compression_quality, fmt, compression_quality // 10) if changed else data
with lopen(path, 'wb') as f:
f.write(image_to_data(img, compression_quality, fmt) if changed else data)
f.write(image_to_data(img, compression_quality, fmt, compression_quality // 10) if changed else data)
# }}}
# Overlaying images {{{
@@ -442,6 +470,20 @@ def quantize_image(img, max_colors=256, dither=True, palette=''):
palette = palette.split()
return imageops.quantize(img, max_colors, dither, [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 {{{
@@ -524,9 +566,10 @@ def optimize_jpeg(file_path):
return run_optimizer(file_path, cmd)
def optimize_png(file_path):
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] + '-fix -clobber -strip all -o7 -out'.split() + [False, True]
cmd = [exe] + '-fix -clobber -strip all -o{} -out'.format(level).split() + [False, True]
return run_optimizer(file_path, cmd)