diff --git a/README.md b/README.md
index 7ee936f..7bc72a2 100644
--- a/README.md
+++ b/README.md
@@ -264,7 +264,7 @@ OUTPUT SETTINGS:
--spreadshift Shift first page to opposite side in landscape for two page spread alignment
--norotate Do not rotate double page spreads in spread splitter option.
--rotatefirst Put rotated spread first in spread splitter option.
- --reducerainbow Reduce rainbow effect on color eink by slightly blurring images
+ --eraserainbow Erase rainbow effect on color eink screen by attenuating interfering frequencies
CUSTOM PROFILE:
--customwidth CUSTOMWIDTH
diff --git a/gui/KCC.ui b/gui/KCC.ui
index 7cbc775..586dcc0 100644
--- a/gui/KCC.ui
+++ b/gui/KCC.ui
@@ -542,12 +542,12 @@
-
-
+
- Reduce rainbow effect on color eink by slightly blurring images
+ Erase rainbow effect on color eink screen by attenuating interfering frequencies
- Rainbow blur
+ Rainbow eraser
@@ -873,7 +873,7 @@
chunkSizeBox
noRotateBox
interPanelCropBox
- reduceRainbowBox
+ eraseRainbowBox
heightBox
croppingPowerSlider
editorButton
diff --git a/kindlecomicconverter/KCC_gui.py b/kindlecomicconverter/KCC_gui.py
index 4744c08..5843e65 100644
--- a/kindlecomicconverter/KCC_gui.py
+++ b/kindlecomicconverter/KCC_gui.py
@@ -262,8 +262,8 @@ class WorkerThread(QThread):
options.batchsplit = 2
if GUI.colorBox.isChecked():
options.forcecolor = True
- if GUI.reduceRainbowBox.isChecked():
- options.reducerainbow = True
+ if GUI.eraseRainbowBox.isChecked():
+ options.eraserainbow = True
if GUI.maximizeStrips.isChecked():
options.maximizestrips = True
if GUI.disableProcessingBox.isChecked():
@@ -875,7 +875,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
'webtoonBox': GUI.webtoonBox.checkState().value,
'outputSplit': GUI.outputSplit.checkState().value,
'colorBox': GUI.colorBox.checkState().value,
- 'reduceRainbowBox': GUI.reduceRainbowBox.checkState().value,
+ 'eraseRainbowBox': GUI.eraseRainbowBox.checkState().value,
'disableProcessingBox': GUI.disableProcessingBox.checkState().value,
'comicinfoTitleBox': GUI.comicinfoTitleBox.checkState().value,
'mozJpegBox': GUI.mozJpegBox.checkState().value,
diff --git a/kindlecomicconverter/KCC_ui.py b/kindlecomicconverter/KCC_ui.py
index f790700..e74aec9 100644
--- a/kindlecomicconverter/KCC_ui.py
+++ b/kindlecomicconverter/KCC_ui.py
@@ -295,10 +295,10 @@ class Ui_mainWindow(object):
self.gridLayout_2.addWidget(self.rotateFirstBox, 8, 1, 1, 1)
- self.reduceRainbowBox = QCheckBox(self.optionWidget)
- self.reduceRainbowBox.setObjectName(u"reduceRainbowBox")
+ self.eraseRainbowBox = QCheckBox(self.optionWidget)
+ self.eraseRainbowBox.setObjectName(u"eraseRainbowBox")
- self.gridLayout_2.addWidget(self.reduceRainbowBox, 7, 2, 1, 1)
+ self.gridLayout_2.addWidget(self.eraseRainbowBox, 7, 2, 1, 1)
self.chunkSizeCheckBox = QCheckBox(self.optionWidget)
self.chunkSizeCheckBox.setObjectName(u"chunkSizeCheckBox")
@@ -473,8 +473,8 @@ class Ui_mainWindow(object):
QWidget.setTabOrder(self.disableProcessingBox, self.chunkSizeBox)
QWidget.setTabOrder(self.chunkSizeBox, self.noRotateBox)
QWidget.setTabOrder(self.noRotateBox, self.interPanelCropBox)
- QWidget.setTabOrder(self.interPanelCropBox, self.reduceRainbowBox)
- QWidget.setTabOrder(self.reduceRainbowBox, self.heightBox)
+ QWidget.setTabOrder(self.interPanelCropBox, self.eraseRainbowBox)
+ QWidget.setTabOrder(self.eraseRainbowBox, self.heightBox)
QWidget.setTabOrder(self.heightBox, self.croppingPowerSlider)
QWidget.setTabOrder(self.croppingPowerSlider, self.editorButton)
QWidget.setTabOrder(self.editorButton, self.wikiButton)
@@ -566,9 +566,9 @@ class Ui_mainWindow(object):
#endif // QT_CONFIG(tooltip)
self.rotateFirstBox.setText(QCoreApplication.translate("mainWindow", u"Rotate First", None))
#if QT_CONFIG(tooltip)
- self.reduceRainbowBox.setToolTip(QCoreApplication.translate("mainWindow", u"Reduce rainbow effect on color eink by slightly blurring images", None))
+ self.eraseRainbowBox.setToolTip(QCoreApplication.translate("mainWindow", u"Erase rainbow effect on color eink screen by attenuating interfering frequencies", None))
#endif // QT_CONFIG(tooltip)
- self.reduceRainbowBox.setText(QCoreApplication.translate("mainWindow", u"Rainbow blur", None))
+ self.eraseRainbowBox.setText(QCoreApplication.translate("mainWindow", u"Rainbow eraser", None))
#if QT_CONFIG(tooltip)
self.chunkSizeCheckBox.setToolTip(QCoreApplication.translate("mainWindow", u"
Unchecked
Maximal output file size is 100 MB for Webtoon, 400 MB for others before split occurs.
Checked
Output file size specified in "Chunk size MB" before split occurs.
", None))
#endif // QT_CONFIG(tooltip)
diff --git a/kindlecomicconverter/comic2ebook.py b/kindlecomicconverter/comic2ebook.py
index 850e47f..4d39410 100755
--- a/kindlecomicconverter/comic2ebook.py
+++ b/kindlecomicconverter/comic2ebook.py
@@ -639,7 +639,7 @@ def imgFileProcessing(work):
workImg = image.ComicPageParser((dirpath, afile), opt)
for i in workImg.payload:
img = image.ComicPage(opt, *i)
-
+ is_color = (opt.forcecolor and img.color)
if opt.cropping == 2 and not opt.webtoon:
img.cropPageNumber(opt.croppingp, opt.croppingm)
if opt.cropping == 1 and not opt.webtoon:
@@ -651,9 +651,9 @@ def imgFileProcessing(work):
img.autocontrastImage()
img.resizeImage()
- img.optimizeForDisplay(opt.reducerainbow)
+ img.optimizeForDisplay(opt.eraserainbow, is_color)
- if opt.forcecolor and img.color:
+ if is_color:
pass
elif opt.forcepng:
img.convertToGrayscale()
@@ -1156,8 +1156,8 @@ def makeParser():
help="Disable autodetection and force white borders")
processing_options.add_argument("--forcecolor", action="store_true", dest="forcecolor", default=False,
help="Don't convert images to grayscale")
- output_options.add_argument("--reducerainbow", action="store_true", dest="reducerainbow", default=False,
- help="Reduce rainbow effect on color eink by slightly blurring images.")
+ output_options.add_argument("--eraserainbow", action="store_true", dest="eraserainbow", default=False,
+ help="Erase rainbow effect on color eink screen by attenuating interfering frequencies")
processing_options.add_argument("--forcepng", action="store_true", dest="forcepng", default=False,
help="Create PNG files instead JPEG")
processing_options.add_argument("--mozjpeg", action="store_true", dest="mozjpeg", default=False,
diff --git a/kindlecomicconverter/image.py b/kindlecomicconverter/image.py
index 09ecc0a..68a55c8 100755
--- a/kindlecomicconverter/image.py
+++ b/kindlecomicconverter/image.py
@@ -381,10 +381,10 @@ class ComicPage:
palImg.putpalette(self.palette)
self.image = self.image.quantize(palette=palImg)
- def optimizeForDisplay(self, reducerainbow):
- # Reduce rainbow artifacts for grayscale images by removing spectral frequencies that cause Moire interference with color filter array
- if reducerainbow and not self.color:
- self.image = erase_rainbow_artifacts(self.image)
+ def optimizeForDisplay(self, eraserainbow, is_color):
+ # Erase rainbow artifacts for grayscale and color images by removing spectral frequencies that cause Moire interference with color filter array
+ if eraserainbow:
+ self.image = erase_rainbow_artifacts(self.image, is_color)
def resizeImage(self):
ratio_device = float(self.size[1]) / float(self.size[0])
diff --git a/kindlecomicconverter/rainbow_artifacts_eraser.py b/kindlecomicconverter/rainbow_artifacts_eraser.py
index 0814084..7135e4d 100644
--- a/kindlecomicconverter/rainbow_artifacts_eraser.py
+++ b/kindlecomicconverter/rainbow_artifacts_eraser.py
@@ -15,7 +15,7 @@ def fourier_transform_image(img):
return fft_result
def attenuate_diagonal_frequencies(fft_spectrum, freq_threshold=0.30, target_angle=135,
- angle_tolerance=10, attenuation_factor=0.15):
+ angle_tolerance=10, attenuation_factor=0.10):
"""
Attenuates specific frequencies in the Fourier domain (optimized version for rfft2).
@@ -31,7 +31,11 @@ def attenuate_diagonal_frequencies(fft_spectrum, freq_threshold=0.30, target_ang
"""
# Get dimensions of the rfft2 result
- height, width_rfft = fft_spectrum.shape
+ if fft_spectrum.ndim == 2:
+ height, width_rfft = fft_spectrum.shape
+ else: # 3D array (color channels)
+ height, width_rfft = fft_spectrum.shape[:2]
+
# For rfft2, the original width is (width_rfft - 1) * 2
width_original = (width_rfft - 1) * 2
@@ -39,6 +43,7 @@ def attenuate_diagonal_frequencies(fft_spectrum, freq_threshold=0.30, target_ang
freq_y = np.fft.fftfreq(height, d=1.0)
freq_x = np.fft.rfftfreq(width_original, d=1.0) # Use rfftfreq for the X dimension
+
# Use broadcasting to create grids without meshgrid (more efficient)
freq_y_grid = freq_y.reshape(-1, 1) # Column
freq_x_grid = freq_x.reshape(1, -1) # Row
@@ -63,8 +68,8 @@ def attenuate_diagonal_frequencies(fft_spectrum, freq_threshold=0.30, target_ang
# Calculation of complementary angle
target_angle_2 = (target_angle + 180) % 360
-
- # Calulation of perpendicular angles (CFA is sometimes orientated at 135°, sometimes at 45°)
+
+ # Calulation of perpendicular angles (135° + 45° to maximize compatibility until we know for sure which angle configure for each device)
target_angle_3 = (target_angle + 90) % 360
target_angle_4 = (target_angle_3 + 180) % 360
@@ -87,23 +92,29 @@ def attenuate_diagonal_frequencies(fft_spectrum, freq_threshold=0.30, target_ang
# Apply attenuation directly (avoid creating a full mask)
if attenuation_factor == 0:
# Special case: complete suppression
- fft_spectrum[combined_condition] = 0
+ if fft_spectrum.ndim == 2:
+ fft_spectrum[combined_condition] = 0
+ else: # 3D array
+ fft_spectrum[combined_condition, :] = 0
return fft_spectrum
elif attenuation_factor == 1:
# Special case: no attenuation
return fft_spectrum
else:
# General case: partial attenuation
- fft_spectrum[combined_condition] *= attenuation_factor
+ if fft_spectrum.ndim == 2:
+ fft_spectrum[combined_condition] *= attenuation_factor
+ else: # 3D array
+ fft_spectrum[combined_condition, :] *= attenuation_factor
return fft_spectrum
-def inverse_fourier_transform_image(fft_spectrum):
+def inverse_fourier_transform_image(fft_spectrum, is_color):
"""
Performs an optimized inverse Fourier transform to reconstruct a PIL image.
Args:
fft_spectrum: Fourier transform result (complex array from rfft2)
- original_shape: Original image shape (height, width) for proper cropping
+ is_color: Boolean indicating if the image is to be treated as color
Returns:
PIL.Image: Reconstructed image
@@ -116,12 +127,114 @@ def inverse_fourier_transform_image(fft_spectrum):
img_reconstructed = img_reconstructed.astype(np.uint8)
# Convert to PIL image
- pil_image = Image.fromarray(img_reconstructed, mode='L')
+ if is_color and img_reconstructed.ndim == 3:
+ pil_image = Image.fromarray(img_reconstructed, mode='RGB')
+ else:
+ pil_image = Image.fromarray(img_reconstructed, mode='L')
return pil_image
+
+def rgb_to_yuv(rgb_array):
+ """
+ Convert RGB to YUV color space.
+ Y = luminance, U and V = chrominance
+ """
+ # Coefficients for RGB to YUV conversion
+ rgb_to_yuv_matrix = np.array([
+ [0.299, 0.587, 0.114], # Y
+ [-0.14713, -0.28886, 0.436], # U
+ [0.615, -0.51499, -0.10001] # V
+ ])
+
+ # Reshape for matrix multiplication
+ original_shape = rgb_array.shape
+ rgb_flat = rgb_array.reshape(-1, 3)
+
+ # Apply transformation
+ yuv_flat = rgb_flat @ rgb_to_yuv_matrix.T
+
+ # Reshape back
+ yuv_array = yuv_flat.reshape(original_shape)
+
+ return yuv_array
+
+def yuv_to_rgb(yuv_array):
+ """
+ Convert YUV to RGB color space.
+ """
+ # Coefficients for YUV to RGB conversion
+ yuv_to_rgb_matrix = np.array([
+ [1.0, 0.0, 1.13983], # R
+ [1.0, -0.39465, -0.58060], # G
+ [1.0, 2.03211, 0.0] # B
+ ])
+
+ # Reshape for matrix multiplication
+ original_shape = yuv_array.shape
+ yuv_flat = yuv_array.reshape(-1, 3)
+
+ # Apply transformation
+ rgb_flat = yuv_flat @ yuv_to_rgb_matrix.T
+
+ # Reshape back
+ rgb_array = rgb_flat.reshape(original_shape)
+
+ return rgb_array
+
+def erase_rainbow_artifacts(img, is_color):
+ """
+ Remove rainbow artifacts from grayscale or color images.
+
+ Args:
+ img: PIL Image (grayscale or RGB)
+ is_color: Boolean indicating if the image is to be treated as color
+
+ Returns:
+ PIL.Image: Cleaned image
+ """
+ # Auto-detect color mode if not specified
+ if is_color is None:
+ color = img.mode in ('RGB', 'RGBA', 'L') and len(np.array(img).shape) == 3
+
+ if is_color and img.mode in ('RGB', 'RGBA'):
+ # Convert to RGB if needed
+ if img.mode == 'RGBA':
+ img = img.convert('RGB')
+
+ # Convert to numpy array
+ img_array = np.array(img, dtype=np.float32)
+
+ # Convert to YUV color space
+ yuv_array = rgb_to_yuv(img_array)
+
+ # Extract luminance channel (Y)
+ luminance = yuv_array[:, :, 0]
+
+ # Process only the luminance channel
+ fft_spectrum = fourier_transform_image(luminance)
+ clean_spectrum = attenuate_diagonal_frequencies(fft_spectrum)
+ clean_luminance = np.fft.irfft2(clean_spectrum)
+
+ # Normalize and clip luminance
+ clean_luminance = np.clip(clean_luminance, 0, 255)
+
+ # Replace luminance in YUV array
+ yuv_array[:, :, 0] = clean_luminance
+
+ # Convert back to RGB
+ rgb_array = yuv_to_rgb(yuv_array)
+ rgb_array = np.clip(rgb_array, 0, 255).astype(np.uint8)
+
+ # Convert back to PIL image
+ clean_image = Image.fromarray(rgb_array, mode='RGB')
+
+ else:
+ # Grayscale processing (original behavior)
+ if img.mode != 'L':
+ img = img.convert('L')
+
+ fft_spectrum = fourier_transform_image(img)
+ clean_spectrum = attenuate_diagonal_frequencies(fft_spectrum)
+ clean_image = inverse_fourier_transform_image(clean_spectrum, is_color)
-def erase_rainbow_artifacts(img):
- fft_spectrum = fourier_transform_image(img)
- clean_spectrum = attenuate_diagonal_frequencies(fft_spectrum)
- clean_image = inverse_fourier_transform_image(clean_spectrum)
return clean_image