diff --git a/src/calibre/gui2/library/bookshelf_view.py b/src/calibre/gui2/library/bookshelf_view.py index 0f33d7c207..d2e2b8d817 100644 --- a/src/calibre/gui2/library/bookshelf_view.py +++ b/src/calibre/gui2/library/bookshelf_view.py @@ -14,7 +14,7 @@ # hover shift change to shift off shelf edge. fix hover transition to next book. # make layout O(1) at least when no grouping is done # wire up cache config widget for bookshelf view -# Implement dominant_color in native code for performance +# Remove py_dominant_color after beta release import hashlib import math import os @@ -71,6 +71,7 @@ from calibre.gui2.momentum_scroll import MomentumScrollMixin from calibre.utils.date import is_date_undefined from calibre.utils.icu import numeric_sort_key from calibre.utils.img import resize_to_fit +from calibre_extensions import imageops TEMPLATE_ERROR_COLOR = QColor('#9C27B0') TEMPLATE_ERROR = _('TEMPLATE ERROR') @@ -133,6 +134,80 @@ def elapsed_time(ref_time: float) -> float: # Cover functions {{{ +def py_dominant_color(self: QImage) -> QColor: + if self.isNull(): + return QColor() + if self._dominant_color is not None: + return self._dominant_color + img = self + if img.width() > 100 or img.height() > 100: + img = self.scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + if (img.format() not in (QImage.Format.Format_RGB32, QImage.Format.Format_ARGB32)): + img = img.convertToFormat( + QImage.Format.Format_ARGB32 if img.hasAlphaChannel() else QImage.Format.Format_RGB32) + color_counts = Counter() + width, height = img.width(), img.height() + stride = img.bytesPerLine() + ptr = img.constBits() + ptr.setsize(img.sizeInBytes()) + view = memoryview(ptr) + for y in range(height): + row_start_idx = y * stride + row_end_idx = row_start_idx + (width * 4) + row_data = view[row_start_idx:row_end_idx] + for i in range(0, len(row_data), 4): + b, g, r = row_data[i:i+3] + # Quantize to 32 levels per channel + # Preserve color variety while grouping similar colors + c = ((r//8)*8, (g//8)*8, (b//8)*8) + color_counts[c] += 1 + if not color_counts: + self._dominant_color = self.DEFAULT_DOMINANT_COLOR + return self._dominant_color + # Find most common color, prefer saturated colors + # Sort by frequency, then by saturation + def color_score(item): + (r, g, b), count = item + # Calculate saturation (how colorful vs gray) + max_val = max(r, g, b) + min_val = min(r, g, b) + if max_val == 0: + saturation = 0 + else: + saturation = (max_val - min_val) / max_val + # Weight by frequency and saturation + return (count, saturation * 100) + + # Get top colors by frequency + sorted_colors = sorted(color_counts.items(), key=color_score, reverse=True) + + # Avoid desaturated gray/brown colors + dominant_color = sorted_colors[0][0] + + # Look for more vibrant alternative if needed + r, g, b = dominant_color + max_val = max(r, g, b) + min_val = min(r, g, b) + saturation = (max_val - min_val) / max_val if max_val > 0 else 0 + + # Try to find more colorful alternatives + if saturation < 0.2 and len(sorted_colors) > 1: + num_pixels = self.width() * self.height() + for (r2, g2, b2), count in sorted_colors[1:5]: # Check top 5 alternatives + max_val2 = max(r2, g2, b2) + min_val2 = min(r2, g2, b2) + sat2 = (max_val2 - min_val2) / max_val2 if max_val2 > 0 else 0 + # Use if more saturated and reasonably frequent + if sat2 > 0.3 and count > num_pixels * 0.05: # At least 5% of pixels + dominant_color = (r2, g2, b2) + break + self._dominant_color = QColor(*dominant_color) + return self._dominant_color + + +dominant_color = getattr(imageops, 'dominant_color', py_dominant_color) # for people running from source + + class ImageWithDominantColor(QImage): _dominant_color: QColor | None = None @@ -140,74 +215,7 @@ class ImageWithDominantColor(QImage): @property def dominant_color(self) -> QColor: - if self.isNull(): - return QColor() - if self._dominant_color is not None: - return self._dominant_color - img = self - if img.width() > 100 or img.height() > 100: - img = self.scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - if (img.format() not in (QImage.Format.Format_RGB32, QImage.Format.Format_ARGB32)): - img = img.convertToFormat( - QImage.Format.Format_ARGB32 if img.hasAlphaChannel() else QImage.Format.Format_RGB32) - color_counts = Counter() - width, height = img.width(), img.height() - stride = img.bytesPerLine() - ptr = img.constBits() - ptr.setsize(img.sizeInBytes()) - view = memoryview(ptr) - for y in range(height): - row_start_idx = y * stride - row_end_idx = row_start_idx + (width * 4) - row_data = view[row_start_idx:row_end_idx] - for i in range(0, len(row_data), 4): - b, g, r = row_data[i:i+3] - # Quantize to 32 levels per channel - # Preserve color variety while grouping similar colors - c = ((r//8)*8, (g//8)*8, (b//8)*8) - color_counts[c] += 1 - if not color_counts: - self._dominant_color = self.DEFAULT_DOMINANT_COLOR - return self._dominant_color - # Find most common color, prefer saturated colors - # Sort by frequency, then by saturation - def color_score(item): - (r, g, b), count = item - # Calculate saturation (how colorful vs gray) - max_val = max(r, g, b) - min_val = min(r, g, b) - if max_val == 0: - saturation = 0 - else: - saturation = (max_val - min_val) / max_val - # Weight by frequency and saturation - return (count, saturation * 100) - - # Get top colors by frequency - sorted_colors = sorted(color_counts.items(), key=color_score, reverse=True) - - # Avoid desaturated gray/brown colors - dominant_color = sorted_colors[0][0] - - # Look for more vibrant alternative if needed - r, g, b = dominant_color - max_val = max(r, g, b) - min_val = min(r, g, b) - saturation = (max_val - min_val) / max_val if max_val > 0 else 0 - - # Try to find more colorful alternatives - if saturation < 0.2 and len(sorted_colors) > 1: - num_pixels = self.width() * self.height() - for (r2, g2, b2), count in sorted_colors[1:5]: # Check top 5 alternatives - max_val2 = max(r2, g2, b2) - min_val2 = min(r2, g2, b2) - sat2 = (max_val2 - min_val2) / max_val2 if max_val2 > 0 else 0 - # Use if more saturated and reasonably frequent - if sat2 > 0.3 and count > num_pixels * 0.05: # At least 5% of pixels - dominant_color = (r2, g2, b2) - break - self._dominant_color = QColor(*dominant_color) - return self._dominant_color + return dominant_color(self) class PixmapWithDominantColor(QPixmap): diff --git a/src/calibre/utils/imageops/imageops.cpp b/src/calibre/utils/imageops/imageops.cpp index dcf18120ee..203aa689a8 100644 --- a/src/calibre/utils/imageops/imageops.cpp +++ b/src/calibre/utils/imageops/imageops.cpp @@ -8,7 +8,9 @@ #include "imageops.h" #include #include +#include #include +#include #include // Macros {{{ @@ -636,6 +638,59 @@ void overlay(const QImage &image, QImage &canvas, unsigned int left, unsigned in } // }}} +QColor dominant_color(const QImage &image) { // {{{ + if (image.isNull()) return QColor(); + QImage img(image); + ENSURE32(img); + QHash colorCounts; + const uchar* bits = img.bits(); + const int bytesPerLine = img.bytesPerLine(); + const int height = img.height(); + const int width = img.width(); + for (int y = 0; y < height; ++y) { + const QRgb* line = reinterpret_cast(bits + y * bytesPerLine); + for (int x = 0; x < width; ++x) { + // Quantize to 32 levels per channel + // Preserve color variety while grouping similar colors + const auto c = qRgb((qRed(line[x])/8)*8, (qGreen(line[x])/8)*8, (qBlue(line[x])/8)*8); + colorCounts[c]++; + } + } + if (colorCounts.size() < 1) return QColor(); + struct ColorCount { + QRgb color; + int count; + mutable int saturation; + }; + QVector sortedColors; + sortedColors.reserve(colorCounts.size()); + for (auto it = colorCounts.constBegin(); it != colorCounts.constEnd(); ++it) { + sortedColors.append({it.key(), it.value(), -1}); + } + int limit = std::min((qsizetype)5, sortedColors.size()); + std::partial_sort(sortedColors.begin(), sortedColors.begin() + limit, sortedColors.end(), + [](const ColorCount &a, const ColorCount &b) { + if (a.count != b.count) return a.count > b.count; + if (a.saturation < 0) a.saturation = QColor(a.color).saturation(); + if (b.saturation < 0) b.saturation = QColor(b.color).saturation(); + return a.saturation > b.saturation; + }); + auto ans = sortedColors[0].color; + float saturation = QColor(ans).saturationF(); + // Look for more vibrant alternative if needed + if (saturation < 0.2 && sortedColors.size() > 1) { + const int min_num_pixels = (int)(0.05 * width * height); + for (qsizetype i = 1; i < limit; i++) { + float q = QColor(sortedColors[i].color).saturationF(); + if (q > 0.3 && sortedColors[i].count > min_num_pixels) { + ans = sortedColors[i].color; + break; + } + } + } + return QColor(ans); +} // }}} + QImage normalize(const QImage &image) { // {{{ ScopedGILRelease PyGILRelease; IntegerPixel intensity; diff --git a/src/calibre/utils/imageops/imageops.h b/src/calibre/utils/imageops/imageops.h index 9a5578230e..150be5b4ea 100644 --- a/src/calibre/utils/imageops/imageops.h +++ b/src/calibre/utils/imageops/imageops.h @@ -24,6 +24,7 @@ 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); +QColor dominant_color(const QImage &image); class ScopedGILRelease { public: diff --git a/src/calibre/utils/imageops/imageops.sip b/src/calibre/utils/imageops/imageops.sip index 000ba1fe09..befdd5e6eb 100644 --- a/src/calibre/utils/imageops/imageops.sip +++ b/src/calibre/utils/imageops/imageops.sip @@ -73,6 +73,14 @@ QImage oil_paint(const QImage &image, const float radius=-1, const bool high_qua sipRes = new QImage(oil_paint(*a0, a1, a2)); IMAGEOPS_SUFFIX %End + +QColor dominant_color(const QImage &image); +%MethodCode + IMAGEOPS_PREFIX + sipRes = new QColor(dominant_color(*a0)); + IMAGEOPS_SUFFIX +%End + QImage quantize(const QImage &image, unsigned int maximum_colors, bool dither, const QList &palette); %MethodCode IMAGEOPS_PREFIX