Use a native implementation of dominant_color() for performance

This commit is contained in:
Kovid Goyal
2025-12-15 10:55:05 +05:30
parent a7c2d8b5cb
commit be83055dce
4 changed files with 141 additions and 69 deletions
+77 -69
View File
@@ -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):
+55
View File
@@ -8,7 +8,9 @@
#include "imageops.h"
#include <stdexcept>
#include <QVector>
#include <QHash>
#include <cmath>
#include <algorithm>
#include <set>
// 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<QRgb, int> 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<const QRgb*>(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<ColorCount> 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;
+1
View File
@@ -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:
+8
View File
@@ -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<QRgb> &palette);
%MethodCode
IMAGEOPS_PREFIX