Skip to content

Commit fd7cd98

Browse files
committed
Add blurhash implementation to Quotient
So every client that wants to implement blurhashes doesn't have to copy this file over and over.
1 parent 8cb40af commit fd7cd98

File tree

5 files changed

+529
-0
lines changed

5 files changed

+529
-0
lines changed

CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ target_sources(${QUOTIENT_LIB_NAME} PUBLIC FILE_SET HEADERS BASE_DIRS .
144144
Quotient/eventitem.h
145145
Quotient/accountregistry.h
146146
Quotient/mxcreply.h
147+
Quotient/blurhash.h
147148
Quotient/events/event.h
148149
Quotient/events/roomevent.h
149150
Quotient/events/stateevent.h
@@ -219,6 +220,7 @@ target_sources(${QUOTIENT_LIB_NAME} PUBLIC FILE_SET HEADERS BASE_DIRS .
219220
Quotient/eventitem.cpp
220221
Quotient/accountregistry.cpp
221222
Quotient/mxcreply.cpp
223+
Quotient/blurhash.cpp
222224
Quotient/events/event.cpp
223225
Quotient/events/roomevent.cpp
224226
Quotient/events/stateevent.cpp

Quotient/blurhash.cpp

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// SPDX-FileCopyrightText: 2024 Joshua Goins <[email protected]>
2+
// SPDX-License-Identifier: MIT
3+
4+
#include "blurhash.h"
5+
6+
#include <QtGui/QColorSpace>
7+
8+
// From https://github.com/woltapp/blurhash/blob/master/Algorithm.md#base-83
9+
const static QString b83Characters{QStringLiteral("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~")};
10+
11+
const static auto toLinearSRGB = QColorSpace(QColorSpace::SRgb).transformationToColorSpace(QColorSpace::SRgbLinear);
12+
const static auto fromLinearSRGB = QColorSpace(QColorSpace::SRgbLinear).transformationToColorSpace(QColorSpace::SRgb);
13+
14+
using namespace Quotient;
15+
16+
QImage BlurHash::decode(const QString &blurhash, const QSize &size)
17+
{
18+
// 10 is the minimum length of a blurhash string
19+
if (blurhash.length() < 10)
20+
return {};
21+
22+
// First character is the number of components
23+
const auto components83 = decode83(blurhash.first(1));
24+
if (!components83.has_value())
25+
return {};
26+
27+
const auto [componentX, componentY] = unpackComponents(*components83);
28+
const auto minimumSize = 1 + 1 + 4 + (componentX * componentY - 1) * 2;
29+
if (componentX < 1 || componentY < 1 || blurhash.size() != minimumSize)
30+
return {};
31+
32+
// Second character is the maximum AC component value
33+
const auto maxAC83 = decode83(blurhash.mid(1, 1));
34+
if (!maxAC83.has_value())
35+
return {};
36+
37+
const auto maxAC = decodeMaxAC(*maxAC83);
38+
39+
// Third character onward is the average color of the image
40+
const auto averageColor83 = decode83(blurhash.mid(2, 4));
41+
if (!averageColor83.has_value())
42+
return {};
43+
44+
const auto averageColor = toLinearSRGB.map(decodeAverageColor(*averageColor83));
45+
46+
QList values = {averageColor};
47+
48+
// Iterate through the rest of the string for the color values
49+
// Each AC component is two characters each
50+
for (qsizetype c = 6; c < blurhash.size(); c += 2) {
51+
const auto acComponent83 = decode83(blurhash.mid(c, 2));
52+
if (!acComponent83.has_value())
53+
return {};
54+
55+
values.append(decodeAC(*acComponent83, maxAC));
56+
}
57+
58+
QImage image(size, QImage::Format_RGB888);
59+
image.setColorSpace(QColorSpace::SRgb);
60+
61+
const auto basisX = calculateWeights(size.width(), componentX);
62+
const auto basisY = calculateWeights(size.height(), componentY);
63+
64+
for (int y = 0; y < size.height(); y++) {
65+
for (int x = 0; x < size.width(); x++) {
66+
float linearSumR = 0.0f;
67+
float linearSumG = 0.0f;
68+
float linearSumB = 0.0f;
69+
70+
for (int nx = 0; nx < componentX; nx++) {
71+
for (int ny = 0; ny < componentY; ny++) {
72+
const float basis = basisX[x * componentX + nx] * basisY[y * componentY + ny];
73+
74+
linearSumR += values[nx + ny * componentX].redF() * basis;
75+
linearSumG += values[nx + ny * componentX].greenF() * basis;
76+
linearSumB += values[nx + ny * componentX].blueF() * basis;
77+
}
78+
}
79+
80+
auto linearColor = QColor::fromRgbF(linearSumR, linearSumG, linearSumB);
81+
image.setPixelColor(x, y, fromLinearSRGB.map(linearColor));
82+
}
83+
}
84+
85+
return image;
86+
}
87+
88+
QString BlurHash::encode(const QImage &image, const int componentsX, const int componentsY)
89+
{
90+
Q_ASSERT(componentsX >= 1 && componentsX <= 9);
91+
Q_ASSERT(componentsY >= 1 && componentsY <= 9);
92+
93+
if (image.isNull())
94+
return {};
95+
96+
const auto basisX = calculateWeights(image.width(), componentsX);
97+
const auto basisY = calculateWeights(image.height(), componentsY);
98+
99+
QList<QColor> factors;
100+
factors.resize(componentsX * componentsY);
101+
102+
const float normalizationFactor = 1.0f / static_cast<float>(image.width());
103+
104+
for (int y = 0; y < image.height(); y++) {
105+
for (int x = 0; x < image.width(); x++) {
106+
const QColor srgbColor = image.pixelColor(x, y);
107+
const QColor linearColor = toLinearSRGB.map(srgbColor);
108+
109+
float linearR = linearColor.redF();
110+
float linearG = linearColor.greenF();
111+
float linearB = linearColor.blueF();
112+
113+
linearR *= normalizationFactor;
114+
linearG *= normalizationFactor;
115+
linearB *= normalizationFactor;
116+
117+
for (int ny = 0; ny < componentsY; ny++) {
118+
for (int nx = 0; nx < componentsX; nx++) {
119+
const float basis = basisX[x * componentsX + nx] * basisY[y * componentsY + ny];
120+
121+
float factorR = factors[ny * componentsX + nx].redF();
122+
float factorG = factors[ny * componentsX + nx].greenF();
123+
float factorB = factors[ny * componentsX + nx].blueF();
124+
125+
factors[ny * componentsX + nx] = QColor::fromRgbF(factorR + linearR * basis, factorG + linearG * basis, factorB + linearB * basis);
126+
}
127+
}
128+
}
129+
}
130+
131+
// Scale by normalization. Half the scaling is done in the previous loop to prevent going
132+
// too far outside the float range.
133+
for (qsizetype i = 0; i < factors.size(); i++) {
134+
float normalisation = (i == 0) ? 1 : 2;
135+
float scale = normalisation / static_cast<float>(image.height());
136+
137+
float factorR = factors[i].redF() * scale;
138+
float factorG = factors[i].greenF() * scale;
139+
float factorB = factors[i].blueF() * scale;
140+
141+
factors[i] = QColor::fromRgbF(factorR, factorG, factorB);
142+
}
143+
144+
const auto averageColor = factors.takeFirst();
145+
146+
QString encodedString;
147+
encodedString.append(encode83(packComponents(Components(componentsX, componentsY))).rightJustified(1, QLatin1Char('0')));
148+
149+
float maximumValue;
150+
if (!factors.empty()) {
151+
float actualMaximumValue = 0;
152+
for (auto ac : factors) {
153+
actualMaximumValue = std::max({
154+
std::abs(ac.redF()),
155+
std::abs(ac.greenF()),
156+
std::abs(ac.blueF()),
157+
actualMaximumValue,
158+
});
159+
}
160+
161+
int quantisedMaximumValue = encodeMaxAC(actualMaximumValue);
162+
maximumValue = (static_cast<float>(quantisedMaximumValue) + 1) / 166;
163+
encodedString.append(encode83(quantisedMaximumValue).leftJustified(1, QLatin1Char('0')));
164+
} else {
165+
maximumValue = 1;
166+
encodedString.append(encode83(0).leftJustified(1, QLatin1Char('0')));
167+
}
168+
169+
encodedString.append(encode83(encodeAverageColor(fromLinearSRGB.map(averageColor))).leftJustified(4, QLatin1Char('0')));
170+
171+
for (auto ac : factors)
172+
encodedString.append(encode83(encodeAC(ac, maximumValue)).leftJustified(2, QLatin1Char('0')));
173+
174+
return encodedString;
175+
}
176+
177+
std::optional<int> BlurHash::decode83(const QString &encodedString)
178+
{
179+
int temp = 0;
180+
for (const QChar c : encodedString) {
181+
const auto index = b83Characters.indexOf(c);
182+
if (index == -1)
183+
return std::nullopt;
184+
185+
temp = temp * 83 + static_cast<int>(index);
186+
}
187+
188+
return temp;
189+
}
190+
191+
QString BlurHash::encode83(int value)
192+
{
193+
QString buffer;
194+
195+
do {
196+
buffer += b83Characters[value % 83];
197+
} while ((value = value / 83));
198+
199+
std::ranges::reverse(buffer);
200+
201+
return buffer;
202+
}
203+
204+
BlurHash::Components BlurHash::unpackComponents(const int packedComponents)
205+
{
206+
return {packedComponents % 9 + 1, packedComponents / 9 + 1};
207+
}
208+
209+
int BlurHash::packComponents(const Components &components)
210+
{
211+
const auto [componentX, componentY] = components;
212+
return (componentX - 1) + (componentY - 1) * 9;
213+
}
214+
215+
float BlurHash::decodeMaxAC(const int value)
216+
{
217+
return static_cast<float>(value + 1) / 166.f;
218+
}
219+
220+
int BlurHash::encodeMaxAC(const float value)
221+
{
222+
return std::clamp(static_cast<int>(value * 166 - 0.5f), 0, 82);
223+
}
224+
225+
QColor BlurHash::decodeAverageColor(const int encodedValue)
226+
{
227+
const int intR = encodedValue >> 16;
228+
const int intG = (encodedValue >> 8) & 255;
229+
const int intB = encodedValue & 255;
230+
231+
return QColor::fromRgb(intR, intG, intB);
232+
}
233+
234+
int BlurHash::encodeAverageColor(const QColor &averageColor)
235+
{
236+
return (averageColor.red() << 16) + (averageColor.green() << 8) + averageColor.blue();
237+
}
238+
239+
float BlurHash::signPow(const float value, const float exp)
240+
{
241+
return std::copysign(std::pow(std::abs(value), exp), value);
242+
}
243+
244+
QColor BlurHash::decodeAC(const int value, const float maxAC)
245+
{
246+
const auto quantR = value / (19 * 19);
247+
const auto quantG = (value / 19) % 19;
248+
const auto quantB = value % 19;
249+
250+
return QColor::fromRgbF(signPow((static_cast<float>(quantR) - 9) / 9, 2) * maxAC,
251+
signPow((static_cast<float>(quantG) - 9) / 9, 2) * maxAC,
252+
signPow((static_cast<float>(quantB) - 9) / 9, 2) * maxAC);
253+
}
254+
255+
int BlurHash::encodeAC(const QColor value, const float maxAC)
256+
{
257+
const auto quantR = static_cast<int>(std::max(0., std::min(18., std::floor(signPow(value.redF() / maxAC, 0.5) * 9 + 9.5))));
258+
const auto quantG = static_cast<int>(std::max(0., std::min(18., std::floor(signPow(value.greenF() / maxAC, 0.5) * 9 + 9.5))));
259+
const auto quantB = static_cast<int>(std::max(0., std::min(18., std::floor(signPow(value.blueF() / maxAC, 0.5) * 9 + 9.5))));
260+
261+
return quantR * 19 * 19 + quantG * 19 + quantB;
262+
}
263+
264+
QList<float> BlurHash::calculateWeights(const qsizetype dimension, const qsizetype components)
265+
{
266+
QList<float> bases(dimension * components, 0.0f);
267+
268+
const auto scale = static_cast<float>(std::numbers::pi) / static_cast<float>(dimension);
269+
for (qsizetype x = 0; x < dimension; x++) {
270+
for (qsizetype nx = 0; nx < components; nx++) {
271+
bases[x * components + nx] = std::cos(scale * static_cast<float>(nx * x));
272+
}
273+
}
274+
return bases;
275+
}

Quotient/blurhash.h

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// SPDX-FileCopyrightText: 2024 Joshua Goins <[email protected]>
2+
// SPDX-License-Identifier: MIT
3+
4+
#pragma once
5+
6+
#include "quotient_export.h"
7+
8+
#include <QtGui/QImage>
9+
10+
class TestBlurHash;
11+
12+
namespace Quotient {
13+
/**
14+
* @brief Encodes and decodes image to and from the BlurHash format. See https://blurha.sh/.
15+
*/
16+
class QUOTIENT_API BlurHash
17+
{
18+
public:
19+
/** Decodes the @p blurhash string creating an image of @p size.
20+
* @note Returns a null image if decoding failed.
21+
*/
22+
static QUOTIENT_API QImage decode(const QString &blurhash, const QSize &size);
23+
24+
/** Encodes the @p image and returns a blurhash string.
25+
* @param image A non-null image.
26+
* @param componentsX the number of components X-wise. Must be between 1 and 9.
27+
* @param componentsY the number of components Y-wise. Must be between 1 and 9.
28+
* @note Returns an empty string if it failed to encode the image.
29+
*/
30+
static QUOTIENT_API QString encode(const QImage &image, int componentsX = 4, int componentsY = 4);
31+
32+
protected:
33+
using Components = std::pair<int, int>;
34+
35+
/**
36+
* @brief Decodes a base 83 string to it's integer value. Returns std::nullopt if there's an invalid character in the blurhash.
37+
*/
38+
static QUOTIENT_API std::optional<int> decode83(const QString &encodedString);
39+
40+
/**
41+
* @brief Encodes an integer to it's base 83 representation.
42+
*/
43+
static QUOTIENT_API QString encode83(int value);
44+
45+
/**
46+
* @brief Unpacks an integer to it's @c Components value.
47+
*/
48+
static QUOTIENT_API Components unpackComponents(int packedComponents);
49+
50+
/**
51+
* @brief Packs @c Components to it's integer representation.
52+
*/
53+
static QUOTIENT_API int packComponents(const Components &components);
54+
55+
/**
56+
* @brief Decodes a encoded max AC component value.
57+
*/
58+
static QUOTIENT_API float decodeMaxAC(int value);
59+
60+
/**
61+
* @brief Encodes the maximum AC component value to an integer repsentation.
62+
*/
63+
static QUOTIENT_API int encodeMaxAC(float value);
64+
65+
/**
66+
* @brief Decodes the average color from the encoded RGB value.
67+
* @note This returns the color as SRGB.
68+
*/
69+
static QUOTIENT_API QColor decodeAverageColor(int encodedValue);
70+
71+
/**
72+
* @brief Encodes the average color into it's integer representation.
73+
*/
74+
static QUOTIENT_API int encodeAverageColor(const QColor &averageColor);
75+
76+
/**
77+
* @brief Calls pow() with @p exp on @p value, while keeping the sign.
78+
*/
79+
static QUOTIENT_API float signPow(float value, float exp);
80+
81+
/**
82+
* @brief Decodes a encoded AC component value.
83+
*/
84+
static QUOTIENT_API QColor decodeAC(int value, float maxAC);
85+
86+
/**
87+
* @brief Encodes the AC component into it's integer representation.
88+
*/
89+
static QUOTIENT_API int encodeAC(QColor value, float maxAC);
90+
91+
/**
92+
* @brief Calculates the weighted sum for @p dimension across @p components.
93+
*/
94+
static QUOTIENT_API QList<float> calculateWeights(qsizetype dimension, qsizetype components);
95+
96+
friend class ::TestBlurHash;
97+
};
98+
} // namespace Quotient

autotests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ quotient_add_test(NAME testcrosssigning)
3737
quotient_add_test(NAME testkeyimport)
3838
quotient_add_test(NAME testsettings)
3939
quotient_add_test(NAME testthread)
40+
quotient_add_test(NAME testblurhash)

0 commit comments

Comments
 (0)