From bf8ccad89ecb48ba628271c2a3ef2d9cdff10bcb Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Fri, 13 Nov 2020 11:04:26 +1000 Subject: [PATCH] Add contrast enhancement options for point cloud RGB renderer --- .../pointcloud/qgspointcloudrenderer.sip.in | 6 + .../qgspointcloudrgbrenderer.sip.in | 74 +++++++++++ .../raster/qgscontrastenhancement.sip.in | 6 +- .../raster/qgsmultibandcolorrenderer.sip.in | 60 ++++++++- src/core/pointcloud/qgspointcloudrenderer.h | 12 ++ .../pointcloud/qgspointcloudrgbrenderer.cpp | 120 ++++++++++++++++++ .../pointcloud/qgspointcloudrgbrenderer.h | 68 ++++++++++ src/core/raster/qgscontrastenhancement.h | 10 +- src/core/raster/qgsmultibandcolorrenderer.h | 54 +++++++- .../python/test_qgspointcloudrgbrenderer.py | 79 +++++++++++- .../expected_rgb_contrast.png | Bin 0 -> 471523 bytes 11 files changed, 474 insertions(+), 15 deletions(-) create mode 100644 tests/testdata/control_images/pointcloudrenderer/expected_rgb_contrast/expected_rgb_contrast.png diff --git a/python/core/auto_generated/pointcloud/qgspointcloudrenderer.sip.in b/python/core/auto_generated/pointcloud/qgspointcloudrenderer.sip.in index c041850c1b9..0dc32e5f162 100644 --- a/python/core/auto_generated/pointcloud/qgspointcloudrenderer.sip.in +++ b/python/core/auto_generated/pointcloud/qgspointcloudrenderer.sip.in @@ -126,6 +126,8 @@ Abstract base class for 2d point cloud renderers. %End public: + QgsPointCloudRenderer(); + virtual ~QgsPointCloudRenderer(); virtual QString type() const = 0; @@ -139,6 +141,8 @@ Create a deep copy of this renderer. Should be implemented by all subclasses and generate a proper subclass. %End + + virtual void renderBlock( const QgsPointCloudBlock *block, QgsPointCloudRenderContext &context ) = 0; %Docstring Renders a ``block`` of point cloud data using the specified render ``context``. @@ -205,6 +209,8 @@ Calls to :py:func:`~QgsPointCloudRenderer.stopRender` must always be preceded by Retrieves the x and y coordinate for the point at index ``i``. %End + private: + QgsPointCloudRenderer( const QgsPointCloudRenderer &other ); }; /************************************************************************ diff --git a/python/core/auto_generated/pointcloud/qgspointcloudrgbrenderer.sip.in b/python/core/auto_generated/pointcloud/qgspointcloudrgbrenderer.sip.in index 142c6d91d5a..c967042c891 100644 --- a/python/core/auto_generated/pointcloud/qgspointcloudrgbrenderer.sip.in +++ b/python/core/auto_generated/pointcloud/qgspointcloudrgbrenderer.sip.in @@ -9,6 +9,7 @@ + class QgsPointCloudRgbRenderer : QgsPointCloudRenderer { %Docstring @@ -26,6 +27,7 @@ An RGB renderer for 2d visualisation of point clouds using embedded red, green a %Docstring Constructor for QgsPointCloudRgbRenderer. %End + virtual QString type() const; virtual QgsPointCloudRenderer *clone() const; @@ -120,6 +122,78 @@ Sets the ``attribute`` to use for the blue channel. .. seealso:: :py:func:`setGreenAttribute` .. seealso:: :py:func:`blueAttribute` +%End + + const QgsContrastEnhancement *redContrastEnhancement() const; +%Docstring +Returns the contrast enchancement to use for the red channel. + +.. seealso:: :py:func:`setRedContrastEnhancement` + +.. seealso:: :py:func:`greenContrastEnhancement` + +.. seealso:: :py:func:`blueContrastEnhancement` +%End + + void setRedContrastEnhancement( QgsContrastEnhancement *enhancement /Transfer/ ); +%Docstring +Sets the contrast ``enchancement`` to use for the red channel. + +Ownership of ``enhancement`` is transferred. + +.. seealso:: :py:func:`redContrastEnhancement` + +.. seealso:: :py:func:`setGreenContrastEnhancement` + +.. seealso:: :py:func:`setBlueContrastEnhancement` +%End + + const QgsContrastEnhancement *greenContrastEnhancement() const; +%Docstring +Returns the contrast enchancement to use for the green channel. + +.. seealso:: :py:func:`setGreenContrastEnhancement` + +.. seealso:: :py:func:`redContrastEnhancement` + +.. seealso:: :py:func:`blueContrastEnhancement` +%End + + void setGreenContrastEnhancement( QgsContrastEnhancement *enhancement /Transfer/ ); +%Docstring +Sets the contrast ``enchancement`` to use for the green channel. + +Ownership of ``enhancement`` is transferred. + +.. seealso:: :py:func:`greenContrastEnhancement` + +.. seealso:: :py:func:`setRedContrastEnhancement` + +.. seealso:: :py:func:`setBlueContrastEnhancement` +%End + + const QgsContrastEnhancement *blueContrastEnhancement() const; +%Docstring +Returns the contrast enchancement to use for the blue channel. + +.. seealso:: :py:func:`setBlueContrastEnhancement` + +.. seealso:: :py:func:`redContrastEnhancement` + +.. seealso:: :py:func:`greenContrastEnhancement` +%End + + void setBlueContrastEnhancement( QgsContrastEnhancement *enhancement /Transfer/ ); +%Docstring +Sets the contrast ``enchancement`` to use for the blue channel. + +Ownership of ``enhancement`` is transferred. + +.. seealso:: :py:func:`blueContrastEnhancement` + +.. seealso:: :py:func:`setRedContrastEnhancement` + +.. seealso:: :py:func:`setGreenContrastEnhancement` %End }; diff --git a/python/core/auto_generated/raster/qgscontrastenhancement.sip.in b/python/core/auto_generated/raster/qgscontrastenhancement.sip.in index 9e90fd16408..77b60c5c2f5 100644 --- a/python/core/auto_generated/raster/qgscontrastenhancement.sip.in +++ b/python/core/auto_generated/raster/qgscontrastenhancement.sip.in @@ -14,7 +14,7 @@ class QgsContrastEnhancement { %Docstring -Manipulates raster pixel values so that they enhanceContrast or clip into a +Manipulates raster or point cloud pixel values so that they enhanceContrast or clip into a specified numerical range according to the specified ContrastEnhancementAlgorithm. %End @@ -40,12 +40,12 @@ ContrastEnhancementAlgorithm. static double maximumValuePossible( Qgis::DataType dataType ); %Docstring -Helper function that returns the maximum possible value for a GDAL data type. +Helper function that returns the maximum possible value for a data type. %End static double minimumValuePossible( Qgis::DataType dataType ); %Docstring -Helper function that returns the minimum possible value for a GDAL data type. +Helper function that returns the minimum possible value for a data type. %End static QString contrastEnhancementAlgorithmString( ContrastEnhancementAlgorithm algorithm ); diff --git a/python/core/auto_generated/raster/qgsmultibandcolorrenderer.sip.in b/python/core/auto_generated/raster/qgsmultibandcolorrenderer.sip.in index ae9c30c08bd..229000688ee 100644 --- a/python/core/auto_generated/raster/qgsmultibandcolorrenderer.sip.in +++ b/python/core/auto_generated/raster/qgsmultibandcolorrenderer.sip.in @@ -45,21 +45,75 @@ QgsMultiBandColorRenderer cannot be copied. Use :py:func:`~QgsMultiBandColorRend void setBlueBand( int band ); const QgsContrastEnhancement *redContrastEnhancement() const; +%Docstring +Returns the contrast enchancement to use for the red channel. + +.. seealso:: :py:func:`setRedContrastEnhancement` + +.. seealso:: :py:func:`greenContrastEnhancement` + +.. seealso:: :py:func:`blueContrastEnhancement` +%End + void setRedContrastEnhancement( QgsContrastEnhancement *ce /Transfer/ ); %Docstring -Takes ownership +Sets the contrast enchancement to use for the red channel. + +Ownership of the enhancement is transferred. + +.. seealso:: :py:func:`redContrastEnhancement` + +.. seealso:: :py:func:`setGreenContrastEnhancement` + +.. seealso:: :py:func:`setBlueContrastEnhancement` %End const QgsContrastEnhancement *greenContrastEnhancement() const; +%Docstring +Returns the contrast enchancement to use for the green channel. + +.. seealso:: :py:func:`setGreenContrastEnhancement` + +.. seealso:: :py:func:`redContrastEnhancement` + +.. seealso:: :py:func:`blueContrastEnhancement` +%End + void setGreenContrastEnhancement( QgsContrastEnhancement *ce /Transfer/ ); %Docstring -Takes ownership +Sets the contrast enchancement to use for the green channel. + +Ownership of the enhancement is transferred. + +.. seealso:: :py:func:`greenContrastEnhancement` + +.. seealso:: :py:func:`setRedContrastEnhancement` + +.. seealso:: :py:func:`setBlueContrastEnhancement` %End const QgsContrastEnhancement *blueContrastEnhancement() const; +%Docstring +Returns the contrast enchancement to use for the blue channel. + +.. seealso:: :py:func:`setBlueContrastEnhancement` + +.. seealso:: :py:func:`redContrastEnhancement` + +.. seealso:: :py:func:`greenContrastEnhancement` +%End + void setBlueContrastEnhancement( QgsContrastEnhancement *ce /Transfer/ ); %Docstring -Takes ownership +Sets the contrast enchancement to use for the blue channel. + +Ownership of the enhancement is transferred. + +.. seealso:: :py:func:`blueContrastEnhancement` + +.. seealso:: :py:func:`setRedContrastEnhancement` + +.. seealso:: :py:func:`setGreenContrastEnhancement` %End virtual void writeXml( QDomDocument &doc, QDomElement &parentElem ) const; diff --git a/src/core/pointcloud/qgspointcloudrenderer.h b/src/core/pointcloud/qgspointcloudrenderer.h index 3f6a3879147..9407d2a7354 100644 --- a/src/core/pointcloud/qgspointcloudrenderer.h +++ b/src/core/pointcloud/qgspointcloudrenderer.h @@ -162,6 +162,8 @@ class CORE_EXPORT QgsPointCloudRenderer public: + QgsPointCloudRenderer() = default; + virtual ~QgsPointCloudRenderer() = default; /** @@ -175,6 +177,12 @@ class CORE_EXPORT QgsPointCloudRenderer */ virtual QgsPointCloudRenderer *clone() const = 0 SIP_FACTORY; + //! QgsPointCloudRenderer cannot be copied -- use clone() instead + QgsPointCloudRenderer( const QgsPointCloudRenderer &other ) = delete; + + //! QgsPointCloudRenderer cannot be copied -- use clone() instead + QgsPointCloudRenderer &operator=( const QgsPointCloudRenderer &other ) = delete; + /** * Renders a \a block of point cloud data using the specified render \a context. */ @@ -242,6 +250,10 @@ class CORE_EXPORT QgsPointCloudRenderer } private: +#ifdef SIP_RUN + QgsPointCloudRenderer( const QgsPointCloudRenderer &other ); +#endif + #ifdef QGISDEBUG //! Pointer to thread in which startRender was first called QThread *mThread = nullptr; diff --git a/src/core/pointcloud/qgspointcloudrgbrenderer.cpp b/src/core/pointcloud/qgspointcloudrgbrenderer.cpp index 32539f748c2..48b273b6e7c 100644 --- a/src/core/pointcloud/qgspointcloudrgbrenderer.cpp +++ b/src/core/pointcloud/qgspointcloudrgbrenderer.cpp @@ -17,6 +17,7 @@ #include "qgspointcloudrgbrenderer.h" #include "qgspointcloudblock.h" +#include "qgscontrastenhancement.h" QgsPointCloudRgbRenderer::QgsPointCloudRgbRenderer() { @@ -35,6 +36,20 @@ QgsPointCloudRenderer *QgsPointCloudRgbRenderer::clone() const res->mRedAttribute = mRedAttribute; res->mGreenAttribute = mGreenAttribute; res->mBlueAttribute = mBlueAttribute; + + if ( mRedContrastEnhancement ) + { + res->setRedContrastEnhancement( new QgsContrastEnhancement( *mRedContrastEnhancement ) ); + } + if ( mGreenContrastEnhancement ) + { + res->setGreenContrastEnhancement( new QgsContrastEnhancement( *mGreenContrastEnhancement ) ); + } + if ( mBlueContrastEnhancement ) + { + res->setBlueContrastEnhancement( new QgsContrastEnhancement( *mBlueContrastEnhancement ) ); + } + return res.release(); } @@ -72,6 +87,10 @@ void QgsPointCloudRgbRenderer::renderBlock( const QgsPointCloudBlock *block, Qgs return; const QgsPointCloudAttribute::DataType blueType = attribute->type(); + const bool useRedContrastEnhancement = mRedContrastEnhancement && mRedContrastEnhancement->contrastEnhancementAlgorithm() != QgsContrastEnhancement::NoEnhancement; + const bool useBlueContrastEnhancement = mBlueContrastEnhancement && mBlueContrastEnhancement->contrastEnhancementAlgorithm() != QgsContrastEnhancement::NoEnhancement; + const bool useGreenContrastEnhancement = mGreenContrastEnhancement && mGreenContrastEnhancement->contrastEnhancementAlgorithm() != QgsContrastEnhancement::NoEnhancement; + int rendered = 0; double x = 0; double y = 0; @@ -150,6 +169,28 @@ void QgsPointCloudRgbRenderer::renderBlock( const QgsPointCloudBlock *block, Qgs break; } + //skip if red, green or blue not in displayable range + if ( ( useRedContrastEnhancement && !mRedContrastEnhancement->isValueInDisplayableRange( red ) ) + || ( useGreenContrastEnhancement && !mGreenContrastEnhancement->isValueInDisplayableRange( green ) ) + || ( useBlueContrastEnhancement && !mBlueContrastEnhancement->isValueInDisplayableRange( blue ) ) ) + { + continue; + } + + //stretch color values + if ( useRedContrastEnhancement ) + { + red = mRedContrastEnhancement->enhanceContrast( red ); + } + if ( useGreenContrastEnhancement ) + { + green = mGreenContrastEnhancement->enhanceContrast( green ); + } + if ( useBlueContrastEnhancement ) + { + blue = mBlueContrastEnhancement->enhanceContrast( blue ); + } + mapToPixel.transformInPlace( x, y ); pen.setColor( QColor( red, green, blue ) ); @@ -172,6 +213,35 @@ QgsPointCloudRenderer *QgsPointCloudRgbRenderer::create( QDomElement &element, c r->setGreenAttribute( element.attribute( QStringLiteral( "green" ), QStringLiteral( "Green" ) ) ); r->setBlueAttribute( element.attribute( QStringLiteral( "blue" ), QStringLiteral( "Blue" ) ) ); + + //contrast enhancements + QgsContrastEnhancement *redContrastEnhancement = nullptr; + QDomElement redContrastElem = element.firstChildElement( QStringLiteral( "redContrastEnhancement" ) ); + if ( !redContrastElem.isNull() ) + { + redContrastEnhancement = new QgsContrastEnhancement( Qgis::UnknownDataType ); + redContrastEnhancement->readXml( redContrastElem ); + r->setRedContrastEnhancement( redContrastEnhancement ); + } + + QgsContrastEnhancement *greenContrastEnhancement = nullptr; + QDomElement greenContrastElem = element.firstChildElement( QStringLiteral( "greenContrastEnhancement" ) ); + if ( !greenContrastElem.isNull() ) + { + greenContrastEnhancement = new QgsContrastEnhancement( Qgis::UnknownDataType ); + greenContrastEnhancement->readXml( greenContrastElem ); + r->setGreenContrastEnhancement( greenContrastEnhancement ); + } + + QgsContrastEnhancement *blueContrastEnhancement = nullptr; + QDomElement blueContrastElem = element.firstChildElement( QStringLiteral( "blueContrastEnhancement" ) ); + if ( !blueContrastElem.isNull() ) + { + blueContrastEnhancement = new QgsContrastEnhancement( Qgis::UnknownDataType ); + blueContrastEnhancement->readXml( blueContrastElem ); + r->setBlueContrastEnhancement( blueContrastEnhancement ); + } + return r.release(); } @@ -186,6 +256,26 @@ QDomElement QgsPointCloudRgbRenderer::save( QDomDocument &doc, const QgsReadWrit rendererElem.setAttribute( QStringLiteral( "green" ), mGreenAttribute ); rendererElem.setAttribute( QStringLiteral( "blue" ), mBlueAttribute ); + //contrast enhancement + if ( mRedContrastEnhancement ) + { + QDomElement redContrastElem = doc.createElement( QStringLiteral( "redContrastEnhancement" ) ); + mRedContrastEnhancement->writeXml( doc, redContrastElem ); + rendererElem.appendChild( redContrastElem ); + } + if ( mGreenContrastEnhancement ) + { + QDomElement greenContrastElem = doc.createElement( QStringLiteral( "greenContrastEnhancement" ) ); + mGreenContrastEnhancement->writeXml( doc, greenContrastElem ); + rendererElem.appendChild( greenContrastElem ); + } + if ( mBlueContrastEnhancement ) + { + QDomElement blueContrastElem = doc.createElement( QStringLiteral( "blueContrastEnhancement" ) ); + mBlueContrastEnhancement->writeXml( doc, blueContrastElem ); + rendererElem.appendChild( blueContrastElem ); + } + return rendererElem; } @@ -245,3 +335,33 @@ void QgsPointCloudRgbRenderer::setBlueAttribute( const QString &blueAttribute ) { mBlueAttribute = blueAttribute; } + +const QgsContrastEnhancement *QgsPointCloudRgbRenderer::redContrastEnhancement() const +{ + return mRedContrastEnhancement.get(); +} + +void QgsPointCloudRgbRenderer::setRedContrastEnhancement( QgsContrastEnhancement *enhancement ) +{ + mRedContrastEnhancement.reset( enhancement ); +} + +const QgsContrastEnhancement *QgsPointCloudRgbRenderer::greenContrastEnhancement() const +{ + return mGreenContrastEnhancement.get(); +} + +void QgsPointCloudRgbRenderer::setGreenContrastEnhancement( QgsContrastEnhancement *enhancement ) +{ + mGreenContrastEnhancement.reset( enhancement ); +} + +const QgsContrastEnhancement *QgsPointCloudRgbRenderer::blueContrastEnhancement() const +{ + return mBlueContrastEnhancement.get(); +} + +void QgsPointCloudRgbRenderer::setBlueContrastEnhancement( QgsContrastEnhancement *enhancement ) +{ + mBlueContrastEnhancement.reset( enhancement ); +} diff --git a/src/core/pointcloud/qgspointcloudrgbrenderer.h b/src/core/pointcloud/qgspointcloudrgbrenderer.h index 9f04e0d6005..107da7476f3 100644 --- a/src/core/pointcloud/qgspointcloudrgbrenderer.h +++ b/src/core/pointcloud/qgspointcloudrgbrenderer.h @@ -22,6 +22,8 @@ #include "qgis_core.h" #include "qgis_sip.h" +class QgsContrastEnhancement; + /** * \ingroup core * An RGB renderer for 2d visualisation of point clouds using embedded red, green and blue attributes. @@ -36,6 +38,7 @@ class CORE_EXPORT QgsPointCloudRgbRenderer : public QgsPointCloudRenderer * Constructor for QgsPointCloudRgbRenderer. */ QgsPointCloudRgbRenderer(); + QString type() const override; QgsPointCloudRenderer *clone() const override; void renderBlock( const QgsPointCloudBlock *block, QgsPointCloudRenderContext &context ) override; @@ -109,7 +112,68 @@ class CORE_EXPORT QgsPointCloudRgbRenderer : public QgsPointCloudRenderer */ void setBlueAttribute( const QString &attribute ); + /** + * Returns the contrast enchancement to use for the red channel. + * + * \see setRedContrastEnhancement() + * \see greenContrastEnhancement() + * \see blueContrastEnhancement() + */ + const QgsContrastEnhancement *redContrastEnhancement() const; + + /** + * Sets the contrast \a enchancement to use for the red channel. + * + * Ownership of \a enhancement is transferred. + * + * \see redContrastEnhancement() + * \see setGreenContrastEnhancement() + * \see setBlueContrastEnhancement() + */ + void setRedContrastEnhancement( QgsContrastEnhancement *enhancement SIP_TRANSFER ); + + /** + * Returns the contrast enchancement to use for the green channel. + * + * \see setGreenContrastEnhancement() + * \see redContrastEnhancement() + * \see blueContrastEnhancement() + */ + const QgsContrastEnhancement *greenContrastEnhancement() const; + + /** + * Sets the contrast \a enchancement to use for the green channel. + * + * Ownership of \a enhancement is transferred. + * + * \see greenContrastEnhancement() + * \see setRedContrastEnhancement() + * \see setBlueContrastEnhancement() + */ + void setGreenContrastEnhancement( QgsContrastEnhancement *enhancement SIP_TRANSFER ); + + /** + * Returns the contrast enchancement to use for the blue channel. + * + * \see setBlueContrastEnhancement() + * \see redContrastEnhancement() + * \see greenContrastEnhancement() + */ + const QgsContrastEnhancement *blueContrastEnhancement() const; + + /** + * Sets the contrast \a enchancement to use for the blue channel. + * + * Ownership of \a enhancement is transferred. + * + * \see blueContrastEnhancement() + * \see setRedContrastEnhancement() + * \see setGreenContrastEnhancement() + */ + void setBlueContrastEnhancement( QgsContrastEnhancement *enhancement SIP_TRANSFER ); + private: + int mPenWidth = 1; int mPainterPenWidth = 1; @@ -117,6 +181,10 @@ class CORE_EXPORT QgsPointCloudRgbRenderer : public QgsPointCloudRenderer QString mGreenAttribute = QStringLiteral( "Green" ); QString mBlueAttribute = QStringLiteral( "Blue" ); + std::unique_ptr< QgsContrastEnhancement > mRedContrastEnhancement; + std::unique_ptr< QgsContrastEnhancement > mGreenContrastEnhancement; + std::unique_ptr< QgsContrastEnhancement > mBlueContrastEnhancement; + }; #endif // QGSPOINTCLOUDRGBRENDERER_H diff --git a/src/core/raster/qgscontrastenhancement.h b/src/core/raster/qgscontrastenhancement.h index 463b4a019c7..3fd040a8c32 100644 --- a/src/core/raster/qgscontrastenhancement.h +++ b/src/core/raster/qgscontrastenhancement.h @@ -35,7 +35,7 @@ class QString; /** * \ingroup core - * Manipulates raster pixel values so that they enhanceContrast or clip into a + * Manipulates raster or point cloud pixel values so that they enhanceContrast or clip into a * specified numerical range according to the specified * ContrastEnhancementAlgorithm. */ @@ -47,8 +47,8 @@ class CORE_EXPORT QgsContrastEnhancement //! \brief This enumerator describes the types of contrast enhancement algorithms that can be used. enum ContrastEnhancementAlgorithm { - NoEnhancement, //this should be the default color scaling algorithm - StretchToMinimumMaximum, //linear histogram enhanceContrast + NoEnhancement, //!< Default color scaling algorithm, no scaling is applied + StretchToMinimumMaximum, //!< Linear histogram StretchAndClipToMinimumMaximum, ClipToMinimumMaximum, UserDefinedEnhancement @@ -61,7 +61,7 @@ class CORE_EXPORT QgsContrastEnhancement const QgsContrastEnhancement &operator=( const QgsContrastEnhancement & ) = delete; /** - * Helper function that returns the maximum possible value for a GDAL data type. + * Helper function that returns the maximum possible value for a data type. */ static double maximumValuePossible( Qgis::DataType dataType ) { @@ -100,7 +100,7 @@ class CORE_EXPORT QgsContrastEnhancement } /** - * Helper function that returns the minimum possible value for a GDAL data type. + * Helper function that returns the minimum possible value for a data type. */ static double minimumValuePossible( Qgis::DataType dataType ) { diff --git a/src/core/raster/qgsmultibandcolorrenderer.h b/src/core/raster/qgsmultibandcolorrenderer.h index 132aa2a591f..5bcf184cfb1 100644 --- a/src/core/raster/qgsmultibandcolorrenderer.h +++ b/src/core/raster/qgsmultibandcolorrenderer.h @@ -55,16 +55,64 @@ class CORE_EXPORT QgsMultiBandColorRenderer: public QgsRasterRenderer int blueBand() const { return mBlueBand; } void setBlueBand( int band ) { mBlueBand = band; } + /** + * Returns the contrast enchancement to use for the red channel. + * + * \see setRedContrastEnhancement() + * \see greenContrastEnhancement() + * \see blueContrastEnhancement() + */ const QgsContrastEnhancement *redContrastEnhancement() const { return mRedContrastEnhancement; } - //! Takes ownership + + /** + * Sets the contrast enchancement to use for the red channel. + * + * Ownership of the enhancement is transferred. + * + * \see redContrastEnhancement() + * \see setGreenContrastEnhancement() + * \see setBlueContrastEnhancement() + */ void setRedContrastEnhancement( QgsContrastEnhancement *ce SIP_TRANSFER ); + /** + * Returns the contrast enchancement to use for the green channel. + * + * \see setGreenContrastEnhancement() + * \see redContrastEnhancement() + * \see blueContrastEnhancement() + */ const QgsContrastEnhancement *greenContrastEnhancement() const { return mGreenContrastEnhancement; } - //! Takes ownership + + /** + * Sets the contrast enchancement to use for the green channel. + * + * Ownership of the enhancement is transferred. + * + * \see greenContrastEnhancement() + * \see setRedContrastEnhancement() + * \see setBlueContrastEnhancement() + */ void setGreenContrastEnhancement( QgsContrastEnhancement *ce SIP_TRANSFER ); + /** + * Returns the contrast enchancement to use for the blue channel. + * + * \see setBlueContrastEnhancement() + * \see redContrastEnhancement() + * \see greenContrastEnhancement() + */ const QgsContrastEnhancement *blueContrastEnhancement() const { return mBlueContrastEnhancement; } - //! Takes ownership + + /** + * Sets the contrast enchancement to use for the blue channel. + * + * Ownership of the enhancement is transferred. + * + * \see blueContrastEnhancement() + * \see setRedContrastEnhancement() + * \see setGreenContrastEnhancement() + */ void setBlueContrastEnhancement( QgsContrastEnhancement *ce SIP_TRANSFER ); void writeXml( QDomDocument &doc, QDomElement &parentElem ) const override; diff --git a/tests/src/python/test_qgspointcloudrgbrenderer.py b/tests/src/python/test_qgspointcloudrgbrenderer.py index 7e6c9516963..270e30bc871 100644 --- a/tests/src/python/test_qgspointcloudrgbrenderer.py +++ b/tests/src/python/test_qgspointcloudrgbrenderer.py @@ -22,7 +22,8 @@ from qgis.core import ( QgsVector3D, QgsMultiRenderChecker, QgsMapSettings, - QgsRectangle + QgsRectangle, + QgsContrastEnhancement ) from qgis.PyQt.QtCore import QDir, QSize @@ -64,10 +65,37 @@ class TestQgsPointCloudRgbRenderer(unittest.TestCase): renderer.setRedAttribute('r') self.assertEqual(renderer.redAttribute(), 'r') + redce = QgsContrastEnhancement() + redce.setMinimumValue(100) + redce.setMaximumValue(120) + redce.setContrastEnhancementAlgorithm(QgsContrastEnhancement.StretchAndClipToMinimumMaximum) + renderer.setRedContrastEnhancement(redce) + + greence = QgsContrastEnhancement() + greence.setMinimumValue(130) + greence.setMaximumValue(150) + greence.setContrastEnhancementAlgorithm(QgsContrastEnhancement.StretchToMinimumMaximum) + renderer.setGreenContrastEnhancement(greence) + + bluece = QgsContrastEnhancement() + bluece.setMinimumValue(170) + bluece.setMaximumValue(190) + bluece.setContrastEnhancementAlgorithm(QgsContrastEnhancement.ClipToMinimumMaximum) + renderer.setBlueContrastEnhancement(bluece) + rr = renderer.clone() self.assertEqual(rr.blueAttribute(), 'b') self.assertEqual(rr.greenAttribute(), 'g') self.assertEqual(rr.redAttribute(), 'r') + self.assertEqual(rr.redContrastEnhancement().minimumValue(), 100) + self.assertEqual(rr.redContrastEnhancement().maximumValue(), 120) + self.assertEqual(rr.redContrastEnhancement().contrastEnhancementAlgorithm(), QgsContrastEnhancement.StretchAndClipToMinimumMaximum) + self.assertEqual(rr.greenContrastEnhancement().minimumValue(), 130) + self.assertEqual(rr.greenContrastEnhancement().maximumValue(), 150) + self.assertEqual(rr.greenContrastEnhancement().contrastEnhancementAlgorithm(), QgsContrastEnhancement.StretchToMinimumMaximum) + self.assertEqual(rr.blueContrastEnhancement().minimumValue(), 170) + self.assertEqual(rr.blueContrastEnhancement().maximumValue(), 190) + self.assertEqual(rr.blueContrastEnhancement().contrastEnhancementAlgorithm(), QgsContrastEnhancement.ClipToMinimumMaximum) doc = QDomDocument("testdoc") elem = renderer.save(doc, QgsReadWriteContext()) @@ -76,6 +104,15 @@ class TestQgsPointCloudRgbRenderer(unittest.TestCase): self.assertEqual(r2.blueAttribute(), 'b') self.assertEqual(r2.greenAttribute(), 'g') self.assertEqual(r2.redAttribute(), 'r') + self.assertEqual(r2.redContrastEnhancement().minimumValue(), 100) + self.assertEqual(r2.redContrastEnhancement().maximumValue(), 120) + self.assertEqual(r2.redContrastEnhancement().contrastEnhancementAlgorithm(), QgsContrastEnhancement.StretchAndClipToMinimumMaximum) + self.assertEqual(r2.greenContrastEnhancement().minimumValue(), 130) + self.assertEqual(r2.greenContrastEnhancement().maximumValue(), 150) + self.assertEqual(r2.greenContrastEnhancement().contrastEnhancementAlgorithm(), QgsContrastEnhancement.StretchToMinimumMaximum) + self.assertEqual(r2.blueContrastEnhancement().minimumValue(), 170) + self.assertEqual(r2.blueContrastEnhancement().maximumValue(), 190) + self.assertEqual(r2.blueContrastEnhancement().contrastEnhancementAlgorithm(), QgsContrastEnhancement.ClipToMinimumMaximum) def testUsedAttributes(self): renderer = QgsPointCloudRgbRenderer() @@ -110,6 +147,46 @@ class TestQgsPointCloudRgbRenderer(unittest.TestCase): TestQgsPointCloudRgbRenderer.report += renderchecker.report() self.assertTrue(result) + @unittest.skipIf('ept' not in QgsProviderRegistry.instance().providerList(), 'EPT provider not available') + def testRenderWithContrast(self): + layer = QgsPointCloudLayer(unitTestDataPath() + '/point_clouds/ept/rgb/ept.json', 'test', 'ept') + self.assertTrue(layer.isValid()) + + layer.renderer().setPenWidth(2) + + redce = QgsContrastEnhancement() + redce.setMinimumValue(100) + redce.setMaximumValue(120) + redce.setContrastEnhancementAlgorithm(QgsContrastEnhancement.StretchToMinimumMaximum) + layer.renderer().setRedContrastEnhancement(redce) + + greence = QgsContrastEnhancement() + greence.setMinimumValue(130) + greence.setMaximumValue(150) + greence.setContrastEnhancementAlgorithm(QgsContrastEnhancement.StretchToMinimumMaximum) + layer.renderer().setGreenContrastEnhancement(greence) + + bluece = QgsContrastEnhancement() + bluece.setMinimumValue(170) + bluece.setMaximumValue(190) + bluece.setContrastEnhancementAlgorithm(QgsContrastEnhancement.StretchToMinimumMaximum) + layer.renderer().setBlueContrastEnhancement(bluece) + + mapsettings = QgsMapSettings() + mapsettings.setOutputSize(QSize(400, 400)) + mapsettings.setOutputDpi(96) + mapsettings.setDestinationCrs(layer.crs()) + mapsettings.setExtent(QgsRectangle(497753.5, 7050887.5, 497754.6, 7050888.6)) + mapsettings.setLayers([layer]) + + renderchecker = QgsMultiRenderChecker() + renderchecker.setMapSettings(mapsettings) + renderchecker.setControlPathPrefix('pointcloudrenderer') + renderchecker.setControlName('expected_rgb_contrast') + result = renderchecker.runTest('expected_rgb_contrast') + TestQgsPointCloudRgbRenderer.report += renderchecker.report() + self.assertTrue(result) + @unittest.skipIf('ept' not in QgsProviderRegistry.instance().providerList(), 'EPT provider not available') def testRenderOpacity(self): layer = QgsPointCloudLayer(unitTestDataPath() + '/point_clouds/ept/rgb/ept.json', 'test', 'ept') diff --git a/tests/testdata/control_images/pointcloudrenderer/expected_rgb_contrast/expected_rgb_contrast.png b/tests/testdata/control_images/pointcloudrenderer/expected_rgb_contrast/expected_rgb_contrast.png new file mode 100644 index 0000000000000000000000000000000000000000..0d5518d85a6e717ff30ea4a9c1af54f284ae1b53 GIT binary patch literal 471523 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 Z%A&jWd|~z*62;0D7cc+qx&QdB{{`wD9bNzc literal 0 HcmV?d00001