From 8dc685df26c0dcd55fc1dca81f01bfe10393fc34 Mon Sep 17 00:00:00 2001 From: Mirco Miranda Date: Mon, 28 Aug 2023 22:25:10 +0000 Subject: [PATCH] qoi: write support As a base I used the reference implementation found on the official site at https://qoiformat.org/ (MIT license). I added a class to convert scan lines in scanlineconverter.cpp. The class takes advantage of the QImage conversion and contrary to what one might expect, with large images it improves performance (compared to converting the whole image) :smile: In progressive mode, for each line, the following conversions (only if needed) are made before saving: 1. If the icc profile is set, the line is converted to sRGB or sRGB Linear. 2. The line is scaled to 8 bits with RGBA order. --- README.md | 2 +- autotests/CMakeLists.txt | 1 + autotests/write/rgb.qoi | Bin 0 -> 1096 bytes autotests/write/rgba.qoi | Bin 0 -> 1390 bytes src/imageformats/CMakeLists.txt | 2 +- src/imageformats/qoi.cpp | 168 ++++++++++++++++++++++--- src/imageformats/qoi_p.h | 1 + src/imageformats/scanlineconverter.cpp | 100 +++++++++++++++ src/imageformats/scanlineconverter_p.h | 79 ++++++++++++ 9 files changed, 337 insertions(+), 16 deletions(-) create mode 100644 autotests/write/rgb.qoi create mode 100644 autotests/write/rgba.qoi create mode 100644 src/imageformats/scanlineconverter.cpp create mode 100644 src/imageformats/scanlineconverter_p.h diff --git a/README.md b/README.md index d66876d..4d2bbbf 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ The following image formats have read-only support: - Photoshop documents (psd, psb, pdd, psdt) - Radiance HDR (hdr) - Sun Raster (ras) -- Quite OK Image format (qoi) The following image formats have read and write support: @@ -31,6 +30,7 @@ The following image formats have read and write support: - High Efficiency Image File Format (heif). Can be enabled with the KIMAGEFORMATS_HEIF build option. - JPEG XL (jxl) - Personal Computer Exchange (pcx) +- Quite OK Image format (qoi) - SGI images (rgb, rgba, sgi, bw) - Softimage PIC (pic) - Targa (tga): supports more formats than Qt's version diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 3026a27..794a651 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -123,6 +123,7 @@ kimageformats_read_tests(FUZZ 1 kimageformats_write_tests( pcx-lossless pic-lossless + qoi-lossless rgb-lossless tga # fixme: the alpha images appear not to be written properly ) diff --git a/autotests/write/rgb.qoi b/autotests/write/rgb.qoi new file mode 100644 index 0000000000000000000000000000000000000000..229070383c07f7d5c36760ffaa449fd8ffd60506 GIT binary patch literal 1096 zcmW-he@xV69LI5P{Zq5HY_>||kNmE!)uP<8Tz{l(E!N6rDq(4sODRv%iH1O^h|Kuq z5{ck|J2(`OScr-w#sS5T6AmtZ>_CqnQvr{=8=e6f~e?IT$ zv*&P1@^>*YF>%pY8M8goy+fULLuEF0^#M#uXHnp)rd%XS?G?%?NqRspv_{ZS!`mIAJo`OQ?QU!_as?iGX>!r zT(i?M*0=b9+%kN*h5Zm8@Cpr)AL&TAO{ z{4^OC4zbn$5eb85h^?I?yR(3^gW3FY{W#6$wS2LtVZ*eIvL!!aL_txMt`(~v%k63~ zalm(k4mRony_K zgvM1=-T>4|U&cxv<`Izu*zj|3%C1J0V@$w?7 zK0QO%iA+3JGen|9RI9kIu~73u2Zr}e%>1*69FArXcKDdjxx~WZJvj>De`eF!l235KuBt?G)9@!;MWQ~xR zdY+xXD!W`-d+W$SquU%OMtnPg;vMy90-IOu2~Q~Wp3Yg9#2mDaL`pCWMR*7DbuX= zmZ8)kn}sOmKO7Vb3|w=p@*Hl|xy?pjtG#Wkn5RdfJ69WqdDR&Of>ytxD^==Hwjd^zNrw^)Ez`^ zc2yAMea->c_3DG22y62F3M)VB`JC=y4h;r%P2nJfc%5VIX2S+8N^eA?-N$LO6}1LtB(6?hO3+o!(#x{=f3o zu%D>!g!Opy6kI1tYT@--W=HhJop+dUZ`QBJ#)`W-L}m<|;HA*ZvGn?&Ez!&UuQPE{ zc)HZ$@%0K(prSs zE6I$`^BMdDHeUB!Xx)P7 + SPDX-FileCopyrightText: 2023 Mirco Miranda SPDX-License-Identifier: LGPL-2.0-or-later */ #include "qoi_p.h" +#include "scanlineconverter_p.h" #include "util_p.h" #include @@ -37,6 +39,10 @@ struct QoiHeader { }; struct Px { + bool operator==(const Px &other) const + { + return r == other.r && g == other.g && b == other.b && a == other.a; + } quint8 r; quint8 g; quint8 b; @@ -53,6 +59,16 @@ static QDataStream &operator>>(QDataStream &s, QoiHeader &head) return s; } +static QDataStream &operator<<(QDataStream &s, const QoiHeader &head) +{ + s << head.MagicNumber; + s << head.Width; + s << head.Height; + s << head.Channels; + s << head.Colorspace; + return s; +} + static bool IsSupported(const QoiHeader &head) { // Check magic number @@ -85,19 +101,8 @@ static QImage::Format imageFormat(const QoiHeader &head) static bool LoadQOI(QIODevice *device, const QoiHeader &qoi, QImage &img) { - Px index[64] = {Px{ - 0, - 0, - 0, - 0, - }}; - - Px px = Px{ - 0, - 0, - 0, - 255, - }; + Px index[64] = {Px{0, 0, 0, 0}}; + Px px = Px{0, 0, 0, 255}; // The px_len should be enough to read a complete "compressed" row: an uncompressible row can become // larger than the row itself. It should never be more than 1/3 (RGB) or 1/4 (RGBA) the length of the @@ -185,6 +190,111 @@ static bool LoadQOI(QIODevice *device, const QoiHeader &qoi, QImage &img) return (ba.startsWith(QByteArray::fromRawData("\x00\x00\x00\x00\x00\x00\x00\x01", 8))); } +static bool SaveQOI(QIODevice *device, const QoiHeader &qoi, const QImage &img) +{ + Px index[64] = {Px{0, 0, 0, 0}}; + Px px = Px{0, 0, 0, 255}; + Px px_prev = px; + + auto run = 0; + auto channels = qoi.Channels; + + QByteArray ba; + ba.reserve(img.width() * channels * 3 / 2); + + ScanLineConverter converter(channels == 3 ? QImage::Format_RGB888 : QImage::Format_RGBA8888); + converter.setTargetColorSpace(QColorSpace(qoi.Colorspace == 1 ? QColorSpace::SRgbLinear : QColorSpace::SRgb)); + + for (auto h = img.height(), y = 0; y < h; ++y) { + auto pixels = converter.convertedScanLine(img, y); + if (pixels == nullptr) { + return false; + } + + for (auto w = img.width() * channels, px_pos = 0; px_pos < w; px_pos += channels) { + px.r = pixels[px_pos + 0]; + px.g = pixels[px_pos + 1]; + px.b = pixels[px_pos + 2]; + + if (channels == 4) { + px.a = pixels[px_pos + 3]; + } + + if (px == px_prev) { + run++; + if (run == 62 || (px_pos == w - channels && y == h - 1)) { + ba.append(QOI_OP_RUN | (run - 1)); + run = 0; + } + } else { + int index_pos; + + if (run > 0) { + ba.append(QOI_OP_RUN | (run - 1)); + run = 0; + } + + index_pos = QoiHash(px) & 0x3F; + + if (index[index_pos] == px) { + ba.append(QOI_OP_INDEX | index_pos); + } else { + index[index_pos] = px; + + if (px.a == px_prev.a) { + signed char vr = px.r - px_prev.r; + signed char vg = px.g - px_prev.g; + signed char vb = px.b - px_prev.b; + + signed char vg_r = vr - vg; + signed char vg_b = vb - vg; + + if (vr > -3 && vr < 2 && vg > -3 && vg < 2 && vb > -3 && vb < 2) { + ba.append(QOI_OP_DIFF | (vr + 2) << 4 | (vg + 2) << 2 | (vb + 2)); + } else if (vg_r > -9 && vg_r < 8 && vg > -33 && vg < 32 && vg_b > -9 && vg_b < 8) { + ba.append(QOI_OP_LUMA | (vg + 32)); + ba.append((vg_r + 8) << 4 | (vg_b + 8)); + } else { + ba.append(char(QOI_OP_RGB)); + ba.append(px.r); + ba.append(px.g); + ba.append(px.b); + } + } else { + ba.append(char(QOI_OP_RGBA)); + ba.append(px.r); + ba.append(px.g); + ba.append(px.b); + ba.append(px.a); + } + } + } + px_prev = px; + } + + auto written = device->write(ba); + if (written < 0) { + return false; + } + if (written) { + ba.remove(0, written); + } + } + + // QOI end of stream + ba.append(QByteArray::fromRawData("\x00\x00\x00\x00\x00\x00\x00\x01", 8)); + + // write remaining data + for (qint64 w = 0, write = 0, size = ba.size(); write < size; write += w) { + w = device->write(ba.constData() + write, size - write); + if (w < 0) { + return false; + } + } + + return true; +} + } // namespace QOIHandler::QOIHandler() @@ -249,6 +359,33 @@ bool QOIHandler::read(QImage *image) return true; } +bool QOIHandler::write(const QImage &image) +{ + if (image.isNull()) { + return false; + } + + QoiHeader qoi; + qoi.MagicNumber = QOI_MAGIC; + qoi.Width = image.width(); + qoi.Height = image.height(); + qoi.Channels = image.hasAlphaChannel() ? 4 : 3; + qoi.Colorspace = image.colorSpace().transferFunction() == QColorSpace::TransferFunction::Linear ? 1 : 0; + + if (!IsSupported(qoi)) { + return false; + } + + QDataStream s(device()); + s.setByteOrder(QDataStream::BigEndian); + s << qoi; + if (s.status() != QDataStream::Ok) { + return false; + } + + return SaveQOI(s.device(), qoi, image); +} + bool QOIHandler::supportsOption(ImageOption option) const { if (option == QImageIOHandler::Size) { @@ -308,7 +445,7 @@ QVariant QOIHandler::option(ImageOption option) const QImageIOPlugin::Capabilities QOIPlugin::capabilities(QIODevice *device, const QByteArray &format) const { if (format == "qoi" || format == "QOI") { - return Capabilities(CanRead); + return Capabilities(CanRead | CanWrite); } if (!format.isEmpty()) { return {}; @@ -321,6 +458,9 @@ QImageIOPlugin::Capabilities QOIPlugin::capabilities(QIODevice *device, const QB if (device->isReadable() && QOIHandler::canRead(device)) { cap |= CanRead; } + if (device->isWritable()) { + cap |= CanWrite; + } return cap; } diff --git a/src/imageformats/qoi_p.h b/src/imageformats/qoi_p.h index 216216b..b125e9f 100644 --- a/src/imageformats/qoi_p.h +++ b/src/imageformats/qoi_p.h @@ -17,6 +17,7 @@ public: bool canRead() const override; bool read(QImage *image) override; + bool write(const QImage &image) override; bool supportsOption(QImageIOHandler::ImageOption option) const override; QVariant option(QImageIOHandler::ImageOption option) const override; diff --git a/src/imageformats/scanlineconverter.cpp b/src/imageformats/scanlineconverter.cpp new file mode 100644 index 0000000..cd19d7a --- /dev/null +++ b/src/imageformats/scanlineconverter.cpp @@ -0,0 +1,100 @@ +/* + SPDX-FileCopyrightText: 2023 Mirco Miranda + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "scanlineconverter_p.h" + +ScanLineConverter::ScanLineConverter(const QImage::Format &targetFormat) + : _targetFormat(targetFormat) +{ +} + +ScanLineConverter::ScanLineConverter(const ScanLineConverter &other) + : _targetFormat(other._targetFormat) + , _colorSpace(other._colorSpace) +{ +} + +ScanLineConverter &ScanLineConverter::operator=(const ScanLineConverter &other) +{ + this->_targetFormat = other._targetFormat; + this->_colorSpace = other._colorSpace; + return (*this); +} + +QImage::Format ScanLineConverter::targetFormat() const +{ + return _targetFormat; +} + +void ScanLineConverter::setTargetColorSpace(const QColorSpace &colorSpace) +{ + _colorSpace = colorSpace; +} + +QColorSpace ScanLineConverter::targetColorSpace() const +{ + return _colorSpace; +} + +const uchar *ScanLineConverter::convertedScanLine(const QImage &image, qint32 y) +{ + auto colorSpaceConversion = isColorSpaceConversionNeeded(image, _colorSpace); + if (image.format() == _targetFormat && !colorSpaceConversion) { + return image.constScanLine(y); + } + if (image.width() != _tmpBuffer.width() || image.format() != _tmpBuffer.format()) { + _tmpBuffer = QImage(image.width(), 1, image.format()); + } + if (_tmpBuffer.isNull()) { + return nullptr; + } + std::memcpy(_tmpBuffer.bits(), image.constScanLine(y), std::min(_tmpBuffer.bytesPerLine(), image.bytesPerLine())); + if (colorSpaceConversion) { + _tmpBuffer.setColorSpace(image.colorSpace()); + _tmpBuffer.convertToColorSpace(_colorSpace); + } + _convBuffer = _tmpBuffer.convertToFormat(_targetFormat); + if (_convBuffer.isNull()) { + return nullptr; + } + return _convBuffer.constBits(); +} + +qsizetype ScanLineConverter::bytesPerLine() const +{ + if (_convBuffer.isNull()) { + return 0; + } + return _convBuffer.bytesPerLine(); +} + +bool ScanLineConverter::isColorSpaceConversionNeeded(const QImage &image, const QColorSpace &targetColorSpace) const +{ + if (image.depth() < 24) { // RGB 8 bit or grater only + return false; + } + auto sourceColorSpace = image.colorSpace(); + if (!sourceColorSpace.isValid() || !targetColorSpace.isValid()) { + return false; + } + + auto stf = sourceColorSpace.transferFunction(); + auto spr = sourceColorSpace.primaries(); + auto ttf = targetColorSpace.transferFunction(); + auto tpr = targetColorSpace.primaries(); + // clang-format off + if (stf == QColorSpace::TransferFunction::Custom || + ttf == QColorSpace::TransferFunction::Custom || + spr == QColorSpace::Primaries::Custom || + tpr == QColorSpace::Primaries::Custom) { + return true; + } + // clang-format on + if (stf == ttf && spr == tpr) { + return false; + } + return true; +} diff --git a/src/imageformats/scanlineconverter_p.h b/src/imageformats/scanlineconverter_p.h new file mode 100644 index 0000000..82cf4aa --- /dev/null +++ b/src/imageformats/scanlineconverter_p.h @@ -0,0 +1,79 @@ +/* + SPDX-FileCopyrightText: 2023 Mirco Miranda + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#ifndef SCANLINECONVERTER_P_H +#define SCANLINECONVERTER_P_H + +#include +#include + +/*! + * \brief The scanlineFormatConversion class + * A class to convert an image scan line. It introduces some overhead on small images + * but performs better on large images. :) + */ +class ScanLineConverter +{ +public: + ScanLineConverter(const QImage::Format &targetFormat); + ScanLineConverter(const ScanLineConverter &other); + ScanLineConverter &operator=(const ScanLineConverter &other); + + /*! + * \brief targetFormat + * \return The target format set in the constructor. + */ + QImage::Format targetFormat() const; + + /*! + * \brief setTargetColorSpace + * Set the colorspace conversion. + * + * In addition to format conversion, it is also possible to convert the color + * space if the source image has a different one set. + * The conversion is done on the source format if and only if the image + * has a color depth greater than 24 bit and the color profile set is different + * from that of the image itself. + * \param colorSpace + */ + void setTargetColorSpace(const QColorSpace &colorSpace); + QColorSpace targetColorSpace() const; + + /*! + * \brief convertedScanLine + * Convert the scanline \a y. + * \note If the image format (and color space) is the same of converted format, it returns the image scan line. + * \return The scan line converted. + */ + const uchar *convertedScanLine(const QImage &image, qint32 y); + + /*! + * \brief bytesPerLine + * \return The size of the last converted scanline. + */ + qsizetype bytesPerLine() const; + + /*! + * \brief isColorSpaceConversionNeeded + * Calculates if a color space conversion is needed. + * \note Only 24 bit or grater images. + * \param image The source image. + * \param targetColorSpace The target color space. + * \return True if the conversion should be done otherwise false. + */ + bool isColorSpaceConversionNeeded(const QImage &image, const QColorSpace &targetColorSpace) const; + +private: + // data + QImage::Format _targetFormat; + QColorSpace _colorSpace; + + // internal buffers + QImage _tmpBuffer; + QImage _convBuffer; +}; + +#endif // SCANLINECONVERTER_P_H