mirror of
https://github.com/ciromattia/kcc
synced 2025-12-13 01:36:27 +00:00
Commit f952634971 moved image corruption detection out from the ComicPage constructor and into a standalone detectCorruption() function. This led to a performance regression because now corruption detection happens in a single thread when it used to be distributed across worker threads, and because a source image is now loaded twice in memory: once during corruption detection and once when actually going to process the image.
Image file corruption detection is now back inside the ComicPage constructor and the extra load() has been removed because the convert() call will automatically invoke load() and most likely throw the same exceptions.
447 lines
18 KiB
Python
Executable File
447 lines
18 KiB
Python
Executable File
# -*- coding: utf-8 -*-
|
|
#
|
|
# Copyright (C) 2010 Alex Yatskov
|
|
# Copyright (C) 2011 Stanislav (proDOOMman) Kosolapov <prodoomman@gmail.com>
|
|
# Copyright (c) 2016 Alberto Planas <aplanas@gmail.com>
|
|
# Copyright (c) 2012-2014 Ciro Mattia Gonano <ciromattia@gmail.com>
|
|
# Copyright (c) 2013-2019 Pawel Jastrzebski <pawelj@iosphe.re>
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
import io
|
|
import os
|
|
import mozjpeg_lossless_optimization
|
|
from PIL import Image, ImageOps, ImageStat, ImageChops, ImageFilter
|
|
from .shared import md5Checksum
|
|
from .page_number_crop_alg import get_bbox_crop_margin_page_number, get_bbox_crop_margin
|
|
from .inter_panel_crop_alg import crop_empty_inter_panel
|
|
|
|
AUTO_CROP_THRESHOLD = 0.015
|
|
|
|
|
|
class ProfileData:
|
|
def __init__(self):
|
|
pass
|
|
|
|
Palette4 = [
|
|
0x00, 0x00, 0x00,
|
|
0x55, 0x55, 0x55,
|
|
0xaa, 0xaa, 0xaa,
|
|
0xff, 0xff, 0xff
|
|
]
|
|
|
|
Palette15 = [
|
|
0x00, 0x00, 0x00,
|
|
0x11, 0x11, 0x11,
|
|
0x22, 0x22, 0x22,
|
|
0x33, 0x33, 0x33,
|
|
0x44, 0x44, 0x44,
|
|
0x55, 0x55, 0x55,
|
|
0x66, 0x66, 0x66,
|
|
0x77, 0x77, 0x77,
|
|
0x88, 0x88, 0x88,
|
|
0x99, 0x99, 0x99,
|
|
0xaa, 0xaa, 0xaa,
|
|
0xbb, 0xbb, 0xbb,
|
|
0xcc, 0xcc, 0xcc,
|
|
0xdd, 0xdd, 0xdd,
|
|
0xff, 0xff, 0xff,
|
|
]
|
|
|
|
Palette16 = [
|
|
0x00, 0x00, 0x00,
|
|
0x11, 0x11, 0x11,
|
|
0x22, 0x22, 0x22,
|
|
0x33, 0x33, 0x33,
|
|
0x44, 0x44, 0x44,
|
|
0x55, 0x55, 0x55,
|
|
0x66, 0x66, 0x66,
|
|
0x77, 0x77, 0x77,
|
|
0x88, 0x88, 0x88,
|
|
0x99, 0x99, 0x99,
|
|
0xaa, 0xaa, 0xaa,
|
|
0xbb, 0xbb, 0xbb,
|
|
0xcc, 0xcc, 0xcc,
|
|
0xdd, 0xdd, 0xdd,
|
|
0xee, 0xee, 0xee,
|
|
0xff, 0xff, 0xff,
|
|
]
|
|
|
|
PalleteNull = [
|
|
]
|
|
|
|
ProfilesKindleEBOK = {
|
|
'K1': ("Kindle 1", (600, 670), Palette4, 1.8),
|
|
'K2': ("Kindle 2", (600, 670), Palette15, 1.8),
|
|
'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.8),
|
|
'K34': ("Kindle Keyboard/Touch", (600, 800), Palette16, 1.8),
|
|
'K578': ("Kindle", (600, 800), Palette16, 1.8),
|
|
'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.8),
|
|
'KV': ("Kindle Paperwhite 3/4/Voyage/Oasis", (1072, 1448), Palette16, 1.8),
|
|
}
|
|
|
|
ProfilesKindlePDOC = {
|
|
'KO': ("Kindle Oasis 2/3/Paperwhite 12/Colorsoft 12", (1264, 1680), Palette16, 1.8),
|
|
'K11': ("Kindle 11", (1072, 1448), Palette16, 1.8),
|
|
'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.8),
|
|
'KS': ("Kindle Scribe", (1860, 2480), Palette16, 1.8),
|
|
}
|
|
|
|
ProfilesKindle = {
|
|
**ProfilesKindleEBOK,
|
|
**ProfilesKindlePDOC
|
|
}
|
|
|
|
ProfilesKobo = {
|
|
'KoMT': ("Kobo Mini/Touch", (600, 800), Palette16, 1.8),
|
|
'KoG': ("Kobo Glo", (768, 1024), Palette16, 1.8),
|
|
'KoGHD': ("Kobo Glo HD", (1072, 1448), Palette16, 1.8),
|
|
'KoA': ("Kobo Aura", (758, 1024), Palette16, 1.8),
|
|
'KoAHD': ("Kobo Aura HD", (1080, 1440), Palette16, 1.8),
|
|
'KoAH2O': ("Kobo Aura H2O", (1080, 1430), Palette16, 1.8),
|
|
'KoAO': ("Kobo Aura ONE", (1404, 1872), Palette16, 1.8),
|
|
'KoN': ("Kobo Nia", (758, 1024), Palette16, 1.8),
|
|
'KoC': ("Kobo Clara HD/Kobo Clara 2E", (1072, 1448), Palette16, 1.8),
|
|
'KoCC': ("Kobo Clara Colour", (1072, 1448), Palette16, 1.8),
|
|
'KoL': ("Kobo Libra H2O/Kobo Libra 2", (1264, 1680), Palette16, 1.8),
|
|
'KoLC': ("Kobo Libra Colour", (1264, 1680), Palette16, 1.8),
|
|
'KoF': ("Kobo Forma", (1440, 1920), Palette16, 1.8),
|
|
'KoS': ("Kobo Sage", (1440, 1920), Palette16, 1.8),
|
|
'KoE': ("Kobo Elipsa", (1404, 1872), Palette16, 1.8),
|
|
}
|
|
|
|
ProfilesRemarkable = {
|
|
'Rmk1': ("reMarkable 1", (1404, 1872), Palette16, 1.8),
|
|
'Rmk2': ("reMarkable 2", (1404, 1872), Palette16, 1.8),
|
|
'RmkPP': ("reMarkable Paper Pro", (1620, 2160), Palette16, 1.8),
|
|
}
|
|
|
|
Profiles = {
|
|
**ProfilesKindle,
|
|
**ProfilesKobo,
|
|
**ProfilesRemarkable,
|
|
'OTHER': ("Other", (0, 0), Palette16, 1.8),
|
|
}
|
|
|
|
|
|
class ComicPageParser:
|
|
def __init__(self, source, options):
|
|
Image.MAX_IMAGE_PIXELS = int(2048 * 2048 * 2048 // 4 // 3)
|
|
self.opt = options
|
|
self.source = source
|
|
self.size = self.opt.profileData[1]
|
|
self.payload = []
|
|
|
|
# Detect corruption in source image, let caller catch any exceptions triggered.
|
|
srcImgPath = os.path.join(source[0], source[1])
|
|
self.image = Image.open(srcImgPath)
|
|
self.image.verify()
|
|
self.image = Image.open(srcImgPath).convert('RGB')
|
|
|
|
self.color = self.colorCheck()
|
|
self.fill = self.fillCheck()
|
|
# backwards compatibility for Pillow >9.1.0
|
|
if not hasattr(Image, 'Resampling'):
|
|
Image.Resampling = Image
|
|
self.splitCheck()
|
|
|
|
def getImageHistogram(self, image):
|
|
histogram = image.histogram()
|
|
if histogram[0] == 0:
|
|
return -1
|
|
elif histogram[255] == 0:
|
|
return 1
|
|
else:
|
|
return 0
|
|
|
|
def splitCheck(self):
|
|
width, height = self.image.size
|
|
dstwidth, dstheight = self.size
|
|
if self.opt.maximizestrips:
|
|
leftbox = (0, 0, int(width / 2), height)
|
|
rightbox = (int(width / 2), 0, width, height)
|
|
if self.opt.righttoleft:
|
|
pageone = self.image.crop(rightbox)
|
|
pagetwo = self.image.crop(leftbox)
|
|
else:
|
|
pageone = self.image.crop(leftbox)
|
|
pagetwo = self.image.crop(rightbox)
|
|
new_image = Image.new("RGB", (int(width / 2), int(height*2)))
|
|
new_image.paste(pageone, (0, 0))
|
|
new_image.paste(pagetwo, (0, height))
|
|
self.payload.append(['N', self.source, new_image, self.color, self.fill])
|
|
elif (width > height) != (dstwidth > dstheight) and width <= dstheight and height <= dstwidth \
|
|
and not self.opt.webtoon and self.opt.splitter == 1:
|
|
spread = self.image
|
|
if not self.opt.norotate:
|
|
spread = spread.rotate(90, Image.Resampling.BICUBIC, True)
|
|
self.payload.append(['R', self.source, spread, self.color, self.fill])
|
|
elif (width > height) != (dstwidth > dstheight) and not self.opt.webtoon:
|
|
if self.opt.splitter != 1:
|
|
if width > height:
|
|
leftbox = (0, 0, int(width / 2), height)
|
|
rightbox = (int(width / 2), 0, width, height)
|
|
else:
|
|
leftbox = (0, 0, width, int(height / 2))
|
|
rightbox = (0, int(height / 2), width, height)
|
|
if self.opt.righttoleft:
|
|
pageone = self.image.crop(rightbox)
|
|
pagetwo = self.image.crop(leftbox)
|
|
else:
|
|
pageone = self.image.crop(leftbox)
|
|
pagetwo = self.image.crop(rightbox)
|
|
self.payload.append(['S1', self.source, pageone, self.color, self.fill])
|
|
self.payload.append(['S2', self.source, pagetwo, self.color, self.fill])
|
|
if self.opt.splitter > 0:
|
|
spread = self.image
|
|
if not self.opt.norotate:
|
|
spread = spread.rotate(90, Image.Resampling.BICUBIC, True)
|
|
self.payload.append(['R', self.source, spread,
|
|
self.color, self.fill])
|
|
else:
|
|
self.payload.append(['N', self.source, self.image, self.color, self.fill])
|
|
|
|
def colorCheck(self):
|
|
if self.opt.webtoon:
|
|
return True
|
|
else:
|
|
img = self.image.copy()
|
|
bands = img.getbands()
|
|
if bands == ('R', 'G', 'B') or bands == ('R', 'G', 'B', 'A'):
|
|
thumb = img.resize((40, 40))
|
|
SSE, bias = 0, [0, 0, 0]
|
|
bias = ImageStat.Stat(thumb).mean[:3]
|
|
bias = [b - sum(bias) / 3 for b in bias]
|
|
for pixel in thumb.getdata():
|
|
mu = sum(pixel) / 3
|
|
SSE += sum((pixel[i] - mu - bias[i]) * (pixel[i] - mu - bias[i]) for i in [0, 1, 2])
|
|
MSE = float(SSE) / (40 * 40)
|
|
if MSE > 22:
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
return False
|
|
|
|
def fillCheck(self):
|
|
if self.opt.bordersColor:
|
|
return self.opt.bordersColor
|
|
else:
|
|
bw = self.image.convert('L').point(lambda x: 0 if x < 128 else 255, '1')
|
|
imageBoxA = bw.getbbox()
|
|
imageBoxB = ImageChops.invert(bw).getbbox()
|
|
if imageBoxA is None or imageBoxB is None:
|
|
surfaceB, surfaceW = 0, 0
|
|
diff = 0
|
|
else:
|
|
surfaceB = (imageBoxA[2] - imageBoxA[0]) * (imageBoxA[3] - imageBoxA[1])
|
|
surfaceW = (imageBoxB[2] - imageBoxB[0]) * (imageBoxB[3] - imageBoxB[1])
|
|
diff = ((max(surfaceB, surfaceW) - min(surfaceB, surfaceW)) / min(surfaceB, surfaceW)) * 100
|
|
if diff > 0.5:
|
|
if surfaceW < surfaceB:
|
|
return 'white'
|
|
elif surfaceW > surfaceB:
|
|
return 'black'
|
|
else:
|
|
fill = 0
|
|
startY = 0
|
|
while startY < bw.size[1]:
|
|
if startY + 5 > bw.size[1]:
|
|
startY = bw.size[1] - 5
|
|
fill += self.getImageHistogram(bw.crop((0, startY, bw.size[0], startY + 5)))
|
|
startY += 5
|
|
startX = 0
|
|
while startX < bw.size[0]:
|
|
if startX + 5 > bw.size[0]:
|
|
startX = bw.size[0] - 5
|
|
fill += self.getImageHistogram(bw.crop((startX, 0, startX + 5, bw.size[1])))
|
|
startX += 5
|
|
if fill > 0:
|
|
return 'black'
|
|
else:
|
|
return 'white'
|
|
|
|
|
|
class ComicPage:
|
|
def __init__(self, options, mode, path, image, color, fill):
|
|
self.opt = options
|
|
_, self.size, self.palette, self.gamma = self.opt.profileData
|
|
if self.opt.hq:
|
|
self.size = (int(self.size[0] * 1.5), int(self.size[1] * 1.5))
|
|
self.kindle_scribe_azw3 = (options.profile == 'KS') and (options.format in ('MOBI', 'EPUB'))
|
|
self.image = image
|
|
self.color = color
|
|
self.fill = fill
|
|
self.rotated = False
|
|
self.orgPath = os.path.join(path[0], path[1])
|
|
if 'N' in mode:
|
|
self.targetPath = os.path.join(path[0], os.path.splitext(path[1])[0]) + '-KCC'
|
|
elif 'R' in mode:
|
|
self.targetPath = os.path.join(path[0], os.path.splitext(path[1])[0]) + '-KCC-A'
|
|
self.rotated = True
|
|
elif 'S1' in mode:
|
|
self.targetPath = os.path.join(path[0], os.path.splitext(path[1])[0]) + '-KCC-B'
|
|
elif 'S2' in mode:
|
|
self.targetPath = os.path.join(path[0], os.path.splitext(path[1])[0]) + '-KCC-C'
|
|
# backwards compatibility for Pillow >9.1.0
|
|
if not hasattr(Image, 'Resampling'):
|
|
Image.Resampling = Image
|
|
|
|
def saveToDir(self):
|
|
try:
|
|
flags = []
|
|
if not self.opt.forcecolor and not self.opt.forcepng:
|
|
self.image = self.image.convert('L')
|
|
if self.rotated:
|
|
flags.append('Rotated')
|
|
if self.fill != 'white':
|
|
flags.append('BlackBackground')
|
|
if self.opt.forcepng:
|
|
self.image.info["transparency"] = None
|
|
self.targetPath += '.png'
|
|
self.image.save(self.targetPath, 'PNG', optimize=1)
|
|
else:
|
|
self.targetPath += '.jpg'
|
|
if self.opt.mozjpeg:
|
|
with io.BytesIO() as output:
|
|
self.image.save(output, format="JPEG", optimize=1, quality=85)
|
|
input_jpeg_bytes = output.getvalue()
|
|
output_jpeg_bytes = mozjpeg_lossless_optimization.optimize(input_jpeg_bytes)
|
|
with open(self.targetPath, "wb") as output_jpeg_file:
|
|
output_jpeg_file.write(output_jpeg_bytes)
|
|
else:
|
|
self.image.save(self.targetPath, 'JPEG', optimize=1, quality=85)
|
|
return [md5Checksum(self.targetPath), flags, self.orgPath]
|
|
except IOError as err:
|
|
raise RuntimeError('Cannot save image. ' + str(err))
|
|
|
|
def autocontrastImage(self):
|
|
gamma = self.opt.gamma
|
|
if gamma < 0.1:
|
|
gamma = self.gamma
|
|
if self.gamma != 1.0 and self.color:
|
|
gamma = 1.0
|
|
if gamma == 1.0:
|
|
self.image = ImageOps.autocontrast(self.image)
|
|
else:
|
|
self.image = ImageOps.autocontrast(Image.eval(self.image, lambda a: int(255 * (a / 255.) ** gamma)))
|
|
|
|
def quantizeImage(self):
|
|
colors = len(self.palette) // 3
|
|
if colors < 256:
|
|
self.palette += self.palette[:3] * (256 - colors)
|
|
palImg = Image.new('P', (1, 1))
|
|
palImg.putpalette(self.palette)
|
|
self.image = self.image.convert('L')
|
|
self.image = self.image.convert('RGB')
|
|
# Quantize is deprecated but new function call it internally anyway...
|
|
self.image = self.image.quantize(palette=palImg)
|
|
|
|
def optimizeForDisplay(self, reducerainbow):
|
|
# Reduce rainbow artifacts for grayscale images by breaking up dither patterns that cause Moire interference with color filter array
|
|
if reducerainbow and not self.color:
|
|
unsharpFilter = ImageFilter.UnsharpMask(radius=1, percent=100)
|
|
self.image = self.image.filter(unsharpFilter)
|
|
self.image = self.image.filter(ImageFilter.BoxBlur(1.0))
|
|
self.image = self.image.filter(unsharpFilter)
|
|
|
|
def resizeImage(self):
|
|
# kindle scribe conversion to mobi is limited in resolution by kindlegen, same with send to kindle and epub
|
|
if self.kindle_scribe_azw3:
|
|
self.size = (1440, 1920)
|
|
ratio_device = float(self.size[1]) / float(self.size[0])
|
|
ratio_image = float(self.image.size[1]) / float(self.image.size[0])
|
|
method = self.resize_method()
|
|
if self.opt.stretch:
|
|
self.image = self.image.resize(self.size, method)
|
|
elif method == Image.Resampling.BICUBIC and not self.opt.upscale:
|
|
if self.opt.format == 'CBZ' or self.opt.kfx:
|
|
borderw = int((self.size[0] - self.image.size[0]) / 2)
|
|
borderh = int((self.size[1] - self.image.size[1]) / 2)
|
|
self.image = ImageOps.expand(self.image, border=(borderw, borderh), fill=self.fill)
|
|
if self.image.size[0] != self.size[0] or self.image.size[1] != self.size[1]:
|
|
self.image = ImageOps.fit(self.image, self.size, method=method)
|
|
else: # if image bigger than device resolution or smaller with upscaling
|
|
if abs(ratio_image - ratio_device) < AUTO_CROP_THRESHOLD:
|
|
self.image = ImageOps.fit(self.image, self.size, method=method)
|
|
elif self.opt.format == 'CBZ' or self.opt.kfx:
|
|
self.image = ImageOps.pad(self.image, self.size, method=method, color=self.fill)
|
|
else:
|
|
if self.kindle_scribe_azw3:
|
|
self.size = (1860, 1920)
|
|
self.image = ImageOps.contain(self.image, self.size, method=method)
|
|
|
|
def resize_method(self):
|
|
if self.image.size[0] <= self.size[0] and self.image.size[1] <= self.size[1]:
|
|
return Image.Resampling.BICUBIC
|
|
else:
|
|
return Image.Resampling.LANCZOS
|
|
|
|
def maybeCrop(self, box, minimum):
|
|
box_area = (box[2] - box[0]) * (box[3] - box[1])
|
|
image_area = self.image.size[0] * self.image.size[1]
|
|
if (box_area / image_area) >= minimum:
|
|
self.image = self.image.crop(box)
|
|
|
|
def cropPageNumber(self, power, minimum):
|
|
bbox = get_bbox_crop_margin_page_number(self.image, power, self.fill)
|
|
|
|
if bbox:
|
|
self.maybeCrop(bbox, minimum)
|
|
|
|
def cropMargin(self, power, minimum):
|
|
bbox = get_bbox_crop_margin(self.image, power, self.fill)
|
|
|
|
if bbox:
|
|
self.maybeCrop(bbox, minimum)
|
|
|
|
def cropInterPanelEmptySections(self, direction):
|
|
self.image = crop_empty_inter_panel(self.image, direction, background_color=self.fill)
|
|
|
|
class Cover:
|
|
def __init__(self, source, target, opt, tomeid):
|
|
self.options = opt
|
|
self.source = source
|
|
self.target = target
|
|
if tomeid == 0:
|
|
self.tomeid = 1
|
|
else:
|
|
self.tomeid = tomeid
|
|
self.image = Image.open(source)
|
|
# backwards compatibility for Pillow >9.1.0
|
|
if not hasattr(Image, 'Resampling'):
|
|
Image.Resampling = Image
|
|
self.process()
|
|
|
|
def process(self):
|
|
self.image = self.image.convert('RGB')
|
|
self.image = ImageOps.autocontrast(self.image)
|
|
if not self.options.forcecolor:
|
|
self.image = self.image.convert('L')
|
|
self.image.thumbnail(self.options.profileData[1], Image.Resampling.LANCZOS)
|
|
self.save()
|
|
|
|
def save(self):
|
|
try:
|
|
self.image.save(self.target, "JPEG", optimize=1, quality=85)
|
|
except IOError:
|
|
raise RuntimeError('Failed to save cover.')
|
|
|
|
def saveToKindle(self, kindle, asin):
|
|
self.image = self.image.resize((300, 470), Image.Resampling.LANCZOS)
|
|
try:
|
|
self.image.save(os.path.join(kindle.path.split('documents')[0], 'system', 'thumbnails',
|
|
'thumbnail_' + asin + '_EBOK_portrait.jpg'), 'JPEG', optimize=1, quality=85)
|
|
except IOError:
|
|
raise RuntimeError('Failed to upload cover.')
|