QGIS API Documentation 3.99.0-Master (d270888f95f)
Loading...
Searching...
No Matches
qgsrenderchecker.cpp
Go to the documentation of this file.
1/***************************************************************************
2 qgsrenderchecker.cpp
3 --------------------------------------
4 Date : 18 Jan 2008
5 Copyright : (C) 2008 by Tim Sutton
6 Email : tim @ linfiniti.com
7 ***************************************************************************
8 * *
9 * This program is free software; you can redistribute it and/or modify *
10 * it under the terms of the GNU General Public License as published by *
11 * the Free Software Foundation; either version 2 of the License, or *
12 * (at your option) any later version. *
13 * *
14 ***************************************************************************/
15
16#include "qgsrenderchecker.h"
17
18#include "qgis.h"
20
21#include <QBuffer>
22#include <QByteArray>
23#include <QColor>
24#include <QCryptographicHash>
25#include <QDebug>
26#include <QImage>
27#include <QImageReader>
28#include <QPainter>
29#include <QString>
30#include <QUuid>
31
32#include "moc_qgsrenderchecker.cpp"
33
34using namespace Qt::StringLiterals;
35
36#ifndef CMAKE_SOURCE_DIR
37#error CMAKE_SOURCE_DIR undefined
38#endif // CMAKE_SOURCE_DIR
39
41{
42 static QString sSourcePathPrefix;
43 static std::once_flag initialized;
44 std::call_once( initialized, []
45 {
46 sSourcePathPrefix = QString( CMAKE_SOURCE_DIR );
47 if ( sSourcePathPrefix.endsWith( '/' ) )
48 sSourcePathPrefix.chop( 1 );
49 } );
50 return sSourcePathPrefix;
51}
52
54 : mBasePath( QStringLiteral( TEST_DATA_DIR ) + u"/control_images/"_s ) //defined in CmakeLists.txt
55{
56 if ( qgetenv( "QGIS_CONTINUOUS_INTEGRATION_RUN" ) == u"true"_s )
57 mIsCiRun = true;
58}
59
61{
62 if ( qgetenv( "QGIS_CONTINUOUS_INTEGRATION_RUN" ) == u"true"_s )
63 return QDir( QDir( "/root/QGIS" ).filePath( u"qgis_test_report"_s ) );
64 else if ( !qgetenv( "QGIS_TEST_REPORT" ).isEmpty() )
65 return QDir( qgetenv( "QGIS_TEST_REPORT" ) );
66 else
67 return QDir( QDir::temp().filePath( u"qgis_test_report"_s ) );
68}
69
71{
72 return true;
73}
74
76{
77 return mBasePath + ( mBasePath.endsWith( '/' ) ? QString() : u"/"_s ) + mControlPathPrefix;
78}
79
80void QgsRenderChecker::setControlImagePath( const QString &path )
81{
82 mBasePath = path;
83}
84
85QString QgsRenderChecker::report( bool ignoreSuccess ) const
86{
87 return ( ( ignoreSuccess && mResult ) || ( mExpectFail && !mResult ) ) ? QString() : mReport;
88}
89
90QString QgsRenderChecker::markdownReport( bool ignoreSuccess ) const
91{
92 return ( ( ignoreSuccess && mResult ) || ( mExpectFail && !mResult ) ) ? QString() : mMarkdownReport;
93}
94
95void QgsRenderChecker::setControlName( const QString &name )
96{
97 mControlName = name;
98 mExpectedImageFile = controlImagePath() + name + '/' + mControlPathSuffix + name + "." + mControlExtension;
99}
100
101void QgsRenderChecker::setControlPathSuffix( const QString &name )
102{
103 if ( !name.isEmpty() )
104 mControlPathSuffix = name + '/';
105 else
106 mControlPathSuffix.clear();
107}
108
109QString QgsRenderChecker::imageToHash( const QString &imageFile )
110{
111 QImage myImage;
112 myImage.load( imageFile );
113 QByteArray myByteArray;
114 QBuffer myBuffer( &myByteArray );
115 myImage.save( &myBuffer, "PNG" );
116 const QString myImageString = QString::fromUtf8( myByteArray.toBase64().data() );
117 QCryptographicHash myHash( QCryptographicHash::Md5 );
118 myHash.addData( myImageString.toUtf8() );
119 return myHash.result().toHex().constData();
120}
121
123{
124 mMapSettings = mapSettings;
125}
126
128{
129 // create a 2x2 checker-board image
130 uchar pixDataRGB[] = { 255, 255, 255, 255,
131 127, 127, 127, 255,
132 127, 127, 127, 255,
133 255, 255, 255, 255
134 };
135
136 const QImage img( pixDataRGB, 2, 2, 8, QImage::Format_ARGB32 );
137 const QPixmap pix = QPixmap::fromImage( img.scaled( 20, 20 ) );
138
139 // fill image with texture
140 QBrush brush;
141 brush.setTexture( pix );
142 QPainter p( image );
143 p.setRenderHint( QPainter::Antialiasing, false );
144 p.fillRect( QRect( 0, 0, image->width(), image->height() ), brush );
145 p.end();
146}
147
148
149bool QgsRenderChecker::isKnownAnomaly( const QString &diffImageFile )
150{
151 const QString myControlImageDir = controlImagePath() + mControlName + '/';
152 const QDir myDirectory = QDir( myControlImageDir );
153 QStringList myList;
154 const QString myFilename = u"*"_s;
155 myList = myDirectory.entryList( QStringList( myFilename ),
156 QDir::Files | QDir::NoSymLinks );
157 //remove the control file from the list as the anomalies are
158 //all files except the control file
159 myList.removeAll( QFileInfo( mExpectedImageFile ).fileName() );
160
161 const QString myImageHash = imageToHash( diffImageFile );
162
163
164 for ( int i = 0; i < myList.size(); ++i )
165 {
166 const QString myFile = myList.at( i );
167 mReport += "<tr><td colspan=3>"
168 "Checking if " + myFile + " is a known anomaly.";
169 mReport += "</td></tr>"_L1;
170 const QString myAnomalyHash = imageToHash( controlImagePath() + mControlName + '/' + myFile );
171 QString myHashMessage = QStringLiteral(
172 "Checking if anomaly %1 (hash %2)<br>" )
173 .arg( myFile,
174 myAnomalyHash );
175 myHashMessage += u"&nbsp; matches %1 (hash %2)"_s
176 .arg( diffImageFile,
177 myImageHash );
178 //foo CDash
179 emitDashMessage( u"Anomaly check"_s, QgsDartMeasurement::Text, myHashMessage );
180
181 mReport += "<tr><td colspan=3>" + myHashMessage + "</td></tr>";
182 if ( myImageHash == myAnomalyHash )
183 {
184 mReport += "<tr><td colspan=3>"
185 "Anomaly found! " + myFile;
186 mReport += "</td></tr>"_L1;
187 return true;
188 }
189 }
190 mReport += "<tr><td colspan=3>"
191 "No anomaly found! ";
192 mReport += "</td></tr>"_L1;
193 return false;
194}
195
196void QgsRenderChecker::emitDashMessage( const QgsDartMeasurement &dashMessage )
197{
198 if ( !mIsCiRun )
199 return;
200
201 if ( mBufferDashMessages )
202 mDashMessages << dashMessage;
203 else
204 dashMessage.send();
205}
206
207void QgsRenderChecker::emitDashMessage( const QString &name, QgsDartMeasurement::Type type, const QString &value )
208{
209 emitDashMessage( QgsDartMeasurement( name, type, value ) );
210}
211
212#if DUMP_BASE64_IMAGES
213void QgsRenderChecker::dumpRenderedImageAsBase64()
214{
215 QFile fileSource( mRenderedImageFile );
216 if ( !fileSource.open( QIODevice::ReadOnly ) )
217 {
218 return;
219 }
220
221 const QByteArray blob = fileSource.readAll();
222 const QByteArray encoded = blob.toBase64();
223 qDebug() << "Dumping rendered image " << mRenderedImageFile << " as base64\n";
224 qDebug() << "################################################################";
225 qDebug() << encoded;
226 qDebug() << "################################################################";
227 qDebug() << "End dump";
228}
229#endif
230
231void QgsRenderChecker::performPostTestActions( Flags flags )
232{
233 if ( mResult || mExpectFail )
234 return;
235
236#if DUMP_BASE64_IMAGES
237 if ( mIsCiRun && QFile::exists( mRenderedImageFile ) && !( flags & Flag::AvoidExportingRenderedImage ) )
238 dumpRenderedImageAsBase64();
239#endif
240
241 if ( shouldGenerateReport() )
242 {
243 const QDir reportDir = QgsRenderChecker::testReportDir();
244 if ( !reportDir.exists() )
245 {
246 if ( !QDir().mkpath( reportDir.path() ) )
247 {
248 qDebug() << "!!!!! cannot create " << reportDir.path();
249 }
250 }
251
252 if ( QFile::exists( mRenderedImageFile ) && !( flags & Flag::AvoidExportingRenderedImage ) )
253 {
254 QFileInfo fi( mRenderedImageFile );
255 const QString destPath = reportDir.filePath( fi.fileName() );
256 if ( QFile::exists( destPath ) )
257 QFile::remove( destPath );
258 if ( !QFile::copy( mRenderedImageFile, destPath ) )
259 {
260 qDebug() << "!!!!! could not copy " << mRenderedImageFile << " to " << destPath;
261 }
262 }
263 if ( QFile::exists( mDiffImageFile ) && !( flags & Flag::AvoidExportingRenderedImage ) )
264 {
265 QFileInfo fi( mDiffImageFile );
266 const QString destPath = reportDir.filePath( fi.fileName() );
267 if ( QFile::exists( destPath ) )
268 QFile::remove( destPath );
269 QFile::copy( mDiffImageFile, destPath );
270 }
271 }
272}
273
274bool QgsRenderChecker::runTest( const QString &testName,
275 unsigned int mismatchCount,
277{
278 mResult = false;
279 if ( mExpectedImageFile.isEmpty() )
280 {
281 qDebug( "QgsRenderChecker::runTest failed - Expected Image File not set." );
282 mReport = "<table>"
283 "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
284 "<tr><td>Nothing rendered</td>\n<td>Failed because Expected "
285 "Image File not set.</td></tr></table>\n";
286 mMarkdownReport = u"Failed because expected image file not set\n"_s;
287 performPostTestActions( flags );
288 return mResult;
289 }
290 //
291 // Load the expected result pixmap
292 //
293 const QImageReader expectedImageReader( mExpectedImageFile );
294 if ( !expectedImageReader.canRead() )
295 {
296 qDebug() << "QgsRenderChecker::runTest failed - Could not load expected image from " << mExpectedImageFile;
297 mReport = "<table>"
298 "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
299 "<tr><td>Nothing rendered</td>\n<td>Failed because Expected "
300 "Image File could not be loaded.</td></tr></table>\n";
301 mMarkdownReport = u"Failed because expected image file (%1) could not be loaded\n"_s.arg( mExpectedImageFile );
302 performPostTestActions( flags );
303 return mResult;
304 }
305
306 const QSize expectedSize = expectedImageReader.size();
307 mMatchTarget = expectedSize.width() * expectedSize.height();
308 //
309 // Now render our layers onto a pixmap
310 //
311 mMapSettings.setBackgroundColor( qRgb( 152, 219, 249 ) );
312 mMapSettings.setFlag( Qgis::MapSettingsFlag::Antialiasing );
313 mMapSettings.setOutputSize( expectedSize / mMapSettings.devicePixelRatio() );
314
315 QElapsedTimer myTime;
316 myTime.start();
317
318 QgsMapRendererSequentialJob job( mMapSettings );
319 job.start();
320 job.waitForFinished();
321
322 mElapsedTime = myTime.elapsed();
323
324 mRenderedImageFile = QDir::tempPath() + '/' + testName + "_result.png";
325
327 Q_ASSERT( mRenderedImage.devicePixelRatioF() == mMapSettings.devicePixelRatio() );
328 const bool res = compareImages( testName, mismatchCount, QString(), flags );
329
330 if ( ! res )
331 {
332 // If test failed, save the pixmap to disk so the user can make a
333 // visual assessment
334 if ( ! mRenderedImage.save( mRenderedImageFile, "PNG", 100 ) )
335 {
336 qDebug() << "QgsRenderChecker::runTest failed - Could not save rendered image to " << mRenderedImageFile;
337 mReport = "<table>"
338 "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
339 "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
340 "Image File could not be saved.</td></tr></table>\n";
341 mMarkdownReport = u"Failed because rendered image file could not be saved to %1\n"_s.arg( mRenderedImageFile );
342
343 performPostTestActions( flags );
344 return mResult;
345 }
346
347 //create a world file to go with the image...
348 QFile wldFile( QDir::tempPath() + '/' + testName + "_result.wld" );
349 if ( wldFile.open( QIODevice::WriteOnly | QIODevice::Truncate ) )
350 {
351 const QgsRectangle r = mMapSettings.extent();
352
353 QTextStream stream( &wldFile );
354 stream << u"%1\r\n0 \r\n0 \r\n%2\r\n%3\r\n%4\r\n"_s
355 .arg( qgsDoubleToString( mMapSettings.mapUnitsPerPixel() ),
356 qgsDoubleToString( -mMapSettings.mapUnitsPerPixel() ),
357 qgsDoubleToString( r.xMinimum() + mMapSettings.mapUnitsPerPixel() / 2.0 ),
358 qgsDoubleToString( r.yMaximum() - mMapSettings.mapUnitsPerPixel() / 2.0 ) );
359 }
360 }
361
362 return res;
363}
364
365
366bool QgsRenderChecker::compareImages( const QString &testName,
367 unsigned int mismatchCount,
368 const QString &renderedImageFile,
370{
371 mResult = false;
372 if ( mExpectedImageFile.isEmpty() )
373 {
374 qDebug( "QgsRenderChecker::runTest failed - Expected Image (control) File not set." );
375 mReport = "<table>"
376 "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
377 "<tr><td>Nothing rendered</td>\n<td>Failed because Expected "
378 "Image File not set.</td></tr></table>\n";
379 mMarkdownReport = u"Failed because expected image file was not set\n"_s;
380
381 performPostTestActions( flags );
382 return mResult;
383 }
384
385 return compareImages( testName, mExpectedImageFile, renderedImageFile, mismatchCount, flags );
386}
387
388bool QgsRenderChecker::compareImages( const QString &testName, const QString &referenceImageFile, const QString &renderedImageFile, unsigned int mismatchCount, QgsRenderChecker::Flags flags )
389{
390 mResult = false;
391 if ( ! renderedImageFile.isEmpty() )
392 {
393 mRenderedImageFile = renderedImageFile;
394#ifdef Q_OS_WIN
395 mRenderedImageFile = mRenderedImageFile.replace( '\\', '/' );
396#endif
397 }
398
399 if ( mRenderedImageFile.isEmpty() )
400 {
401 qDebug( "QgsRenderChecker::runTest failed - Rendered Image File not set." );
402 mReport = "<table>"
403 "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
404 "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
405 "Image File not set.</td></tr></table>\n";
406 mMarkdownReport = u"Failed because rendered image file was not set\n"_s;
407 performPostTestActions( flags );
408 return mResult;
409 }
410
411 //
412 // Load /create the images
413 //
414 QImage expectedImage( referenceImageFile );
415 if ( expectedImage.isNull() )
416 {
417 qDebug() << "QgsRenderChecker::runTest failed - Could not load control image from " << referenceImageFile;
418 mReport = "<table>"
419 "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
420 "<tr><td>Nothing rendered</td>\n<td>Failed because control "
421 "image file could not be loaded.</td></tr></table>\n";
422 mMarkdownReport = u"Failed because expected image file (%1) could not be loaded\n"_s.arg( referenceImageFile );
423 performPostTestActions( flags );
424 return mResult;
425 }
426
427 const QString expectedImageString = u"<a href=\"%1\" style=\"color: inherit\" target=\"_blank\">expected</a> image"_s.arg( QUrl::fromLocalFile( referenceImageFile ).toString() );
428 const QString renderedImageString = u"<a href=\"%2\" style=\"color: inherit\" target=\"_blank\">rendered</a> image"_s.arg( QUrl::fromLocalFile( renderedImageFile ).toString() );
429 auto upperFirst = []( const QString & string ) -> QString
430 {
431 const int firstNonTagIndex = string.indexOf( '>' ) + 1;
432 return string.left( firstNonTagIndex ) + string.at( firstNonTagIndex ).toUpper() + string.mid( firstNonTagIndex + 1 );
433 };
434
435 QImage myResultImage = mRenderedImage.isNull() ? QImage( mRenderedImageFile ) : mRenderedImage;
436 if ( myResultImage.isNull() )
437 {
438 qDebug() << "QgsRenderChecker::runTest failed - Could not load rendered image from " << mRenderedImageFile;
439 mReport = QStringLiteral( "<table>"
440 "<tr><td>Test Result:</td><td>%1:</td></tr>\n"
441 "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
442 "Image File could not be loaded.</td></tr></table>\n" ).arg( upperFirst( expectedImageString ) );
443 mMarkdownReport = u"Failed because rendered image (%1) could not be loaded\n"_s.arg( mRenderedImageFile );
444 performPostTestActions( flags );
445 return mResult;
446 }
447 QImage myDifferenceImage( expectedImage.width(),
448 expectedImage.height(),
449 QImage::Format_RGB32 );
450 mDiffImageFile = QDir::tempPath() + '/' + testName + "_result_diff.png";
451 myDifferenceImage.fill( qRgb( 152, 219, 249 ) );
452
453 //check for mask
454 QString maskImagePath = referenceImageFile;
455 maskImagePath.chop( 4 ); //remove .png extension
456 maskImagePath += "_mask.png"_L1;
457 const QImage maskImage( maskImagePath );
458 const bool hasMask = !maskImage.isNull();
459
460 //
461 // Set pixel count score and target
462 //
463 mMatchTarget = expectedImage.width() * expectedImage.height();
464 const unsigned int myPixelCount = myResultImage.width() * myResultImage.height();
465 //
466 // Set the report with the result
467 //
468 mReport += "<table>"_L1;
469 mReport += "<tr><td colspan=2>"_L1;
470 mReport += QStringLiteral( "<tr><td colspan=2>"
471 "%8 and %9 for %1<br>"
472 "Expected size: %2 w x %3 h (%4 pixels)<br>"
473 "Rendered size: %5 w x %6 h (%7 pixels)"
474 "</td></tr>" )
475 .arg( testName )
476 .arg( expectedImage.width() ).arg( expectedImage.height() ).arg( mMatchTarget )
477 .arg( myResultImage.width() ).arg( myResultImage.height() ).arg( myPixelCount )
478 .arg( upperFirst( expectedImageString ), renderedImageString );
479 mReport += QString( "<tr><td colspan=2>\n"
480 "Expected Duration : <= %1 (0 indicates not specified)<br>"
481 "Actual Duration : %2 ms<br></td></tr>" )
482 .arg( mElapsedTimeTarget )
483 .arg( mElapsedTime );
484
485 // limit image size in page to something reasonable
486 int imgWidth = 420;
487 int imgHeight = 280;
488 if ( ! expectedImage.isNull() )
489 {
490 imgWidth = std::min( expectedImage.width(), imgWidth );
491 imgHeight = expectedImage.height() * imgWidth / expectedImage.width();
492 }
493
494 const QString renderedImageFileName = QFileInfo( mRenderedImageFile ).fileName();
495 const QString diffImageFileName = QFileInfo( mDiffImageFile ).fileName();
496 const QString myImagesString = QString(
497 "<tr>"
498 "<td colspan=2>Compare %10 and %11</td>"
499 "<td>Difference (all blue is good, any red is bad)</td>"
500 "</tr>\n<tr>"
501 "<td colspan=2 id=\"td-%1-%7\"></td>\n"
502 "<td align=center><img width=%5 height=%6 src=\"%2\"></td>\n"
503 "</tr>"
504 "</table>\n"
505 "<script>\naddComparison(\"td-%1-%7\",\"%3\",\"file://%4\",%5,%6);\n</script>\n"
506 "<p>If the new image looks good, create or update a test mask with<br>"
507 "<code onclick=\"copyToClipboard(this)\" class=\"copy-code\" data-tooltip=\"Click to copy\">scripts/generate_test_mask_image.py \"%8\" \"%9\"</code>"
508 )
509 .arg( testName,
510 diffImageFileName,
511 renderedImageFileName,
512 referenceImageFile )
513 .arg( imgWidth ).arg( imgHeight )
514 .arg( QUuid::createUuid().toString().mid( 1, 6 ),
515 referenceImageFile,
517 expectedImageString,
518 renderedImageString
519 );
520
521 QString prefix;
522 if ( !mControlPathPrefix.isNull() )
523 {
524 prefix = u" (prefix %1)"_s.arg( mControlPathPrefix );
525 }
526
527 //
528 // Put the same info to debug too
529 //
530
531 if ( !flags.testFlag( Flag::Silent )
532 && ( expectedImage.width() != myResultImage.width() || expectedImage.height() != myResultImage.height() )
533 )
534 {
535 qDebug( "Expected size: %dw x %dh", expectedImage.width(), expectedImage.height() );
536 qDebug( "Actual size: %dw x %dh", myResultImage.width(), myResultImage.height() );
537 if ( hasMask )
538 qDebug( "Mask size: %dw x %dh", maskImage.width(), maskImage.height() );
539 }
540
541 if ( mMatchTarget != myPixelCount )
542 {
543 if ( !flags.testFlag( Flag::Silent ) )
544 {
545 qDebug( "Expected image and rendered image for %s are different dimensions", testName.toLocal8Bit().constData() );
546 }
547
548 if ( std::abs( expectedImage.width() - myResultImage.width() ) > mMaxSizeDifferenceX ||
549 std::abs( expectedImage.height() - myResultImage.height() ) > mMaxSizeDifferenceY )
550 {
551 emitDashMessage( "Rendered Image " + testName + prefix, QgsDartMeasurement::ImagePng, mRenderedImageFile );
552 emitDashMessage( "Expected Image " + testName + prefix, QgsDartMeasurement::ImagePng, referenceImageFile );
553
554 mReport += "<tr><td colspan=3>"_L1;
555 mReport += u"<font color=red>%1 and %2 for "_s.arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are different dimensions - FAILING!</font>";
556 mReport += "</td></tr>"_L1;
557 mMarkdownReport += u"Failed because rendered image and expected image are different dimensions (%1x%2 v2 %3x%4)\n"_s
558 .arg( myResultImage.width() )
559 .arg( myResultImage.height() )
560 .arg( expectedImage.width() )
561 .arg( expectedImage.height() );
562
563 const QString diffSizeImagesString = QString(
564 "<tr>"
565 "<td colspan=3>Compare %5 and %6</td>"
566 "</tr>\n<tr>"
567 "<td align=center><img width=%3 height=%4 src=\"%2\"></td>\n"
568 "<td align=center><img src=\"%1\"></td>\n"
569 "</tr>"
570 "</table>\n" )
571 .arg(
572 renderedImageFileName,
573 referenceImageFile )
574 .arg( imgWidth ).arg( imgHeight )
575 .arg( expectedImageString, renderedImageString );
576
577 mReport += diffSizeImagesString;
578 performPostTestActions( flags );
579 return mResult;
580 }
581 else
582 {
583 mReport += "<tr><td colspan=3>"_L1;
584 mReport += u"%1 and %2 for "_s.arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are different dimensions, but within tolerance";
585 mReport += "</td></tr>"_L1;
586 }
587 }
588
589 if ( expectedImage.format() == QImage::Format_Indexed8 )
590 {
591 if ( myResultImage.format() != QImage::Format_Indexed8 )
592 {
593 emitDashMessage( "Rendered Image " + testName + prefix, QgsDartMeasurement::ImagePng, mRenderedImageFile );
594 emitDashMessage( "Expected Image " + testName + prefix, QgsDartMeasurement::ImagePng, referenceImageFile );
595
596 qDebug() << "Expected image and rendered image for " << testName << " have different formats (8bit format is expected) - FAILING!";
597
598 mReport += "<tr><td colspan=3>"_L1;
599 mReport += "<font color=red>Expected image and rendered image for " + testName + " have different formats (8bit format is expected) - FAILING!</font>";
600 mReport += "</td></tr>"_L1;
601 mReport += myImagesString;
602
603 mMarkdownReport += "Failed because rendered image and expected image have different formats (8bit format is expected)\n"_L1;
604 performPostTestActions( flags );
605 return mResult;
606 }
607
608 // When we compute the diff between the 2 images, we use constScanLine expecting a QRgb color
609 // but this method returns color table index for 8 bit image, not color.
610 // So we convert the 2 images in 32 bits so the diff works correctly
611 myResultImage = myResultImage.convertToFormat( QImage::Format_ARGB32 );
612 expectedImage = expectedImage.convertToFormat( QImage::Format_ARGB32 );
613 }
614 if ( expectedImage.format() != QImage::Format_RGB32
615 && expectedImage.format() != QImage::Format_ARGB32
616 && expectedImage.format() != QImage::Format_ARGB32_Premultiplied )
617 {
618 mReport += "<tr><td colspan=3>"_L1;
619 mReport += u"<font color=red>Expected image for %1 is not a compatible format (%2). Must be 32 bit RGB format.</font>"_s.arg( testName, qgsEnumValueToKey( expectedImage.format() ) );
620 mReport += "</td></tr>"_L1;
621 mReport += myImagesString;
622
623 mMarkdownReport += u"Failed because expected image has an incompatible format - %1 (32 bit format is expected)\n"_s.arg( qgsEnumValueToKey( expectedImage.format() ) );
624 performPostTestActions( flags );
625 return mResult;
626 }
627 if ( myResultImage.format() != QImage::Format_RGB32
628 && myResultImage.format() != QImage::Format_ARGB32
629 && myResultImage.format() != QImage::Format_ARGB32_Premultiplied )
630 {
631 mReport += "<tr><td colspan=3>"_L1;
632 mReport += u"<font color=red>Rendered image for %1 is not a compatible format (%2). Must be 32 bit RGB format.</font>"_s.arg( testName, qgsEnumValueToKey( myResultImage.format() ) );
633 mReport += "</td></tr>"_L1;
634 mReport += myImagesString;
635
636 mMarkdownReport += u"Failed because rendered image has an incompatible format - %1 (32 bit format is expected)\n"_s.arg( qgsEnumValueToKey( myResultImage.format() ) );
637 performPostTestActions( flags );
638 return mResult;
639 }
640
641 //
642 // Now iterate through them counting how many
643 // dissimilar pixel values there are
644 //
645
646 const int maxHeight = std::min( expectedImage.height(), myResultImage.height() );
647 const int maxWidth = std::min( expectedImage.width(), myResultImage.width() );
648
649 const int maskWidth = maskImage.width();
650
651 mMismatchCount = 0;
652 const int colorTolerance = static_cast< int >( mColorTolerance );
653 for ( int y = 0; y < maxHeight; ++y )
654 {
655 const QRgb *expectedScanline = reinterpret_cast< const QRgb * >( expectedImage.constScanLine( y ) );
656 const QRgb *resultScanline = reinterpret_cast< const QRgb * >( myResultImage.constScanLine( y ) );
657 const QRgb *maskScanline = ( hasMask && maskImage.height() > y ) ? reinterpret_cast< const QRgb * >( maskImage.constScanLine( y ) ) : nullptr;
658 QRgb *diffScanline = reinterpret_cast< QRgb * >( myDifferenceImage.scanLine( y ) );
659
660 for ( int x = 0; x < maxWidth; ++x )
661 {
662 const int pixelTolerance = maskScanline
663 ? std::max( colorTolerance, ( maskWidth > x ) ? qRed( maskScanline[ x ] ) : 0 )
664 : colorTolerance;
665 if ( pixelTolerance == 255 )
666 {
667 //skip pixel
668 continue;
669 }
670
671 const QRgb myExpectedPixel = expectedScanline[x];
672 const QRgb myActualPixel = resultScanline[x];
673 if ( pixelTolerance == 0 )
674 {
675 if ( myExpectedPixel != myActualPixel )
676 {
677 ++mMismatchCount;
678 diffScanline[ x ] = qRgb( 255, 0, 0 );
679 }
680 }
681 else
682 {
683 if ( std::abs( qRed( myExpectedPixel ) - qRed( myActualPixel ) ) > pixelTolerance ||
684 std::abs( qGreen( myExpectedPixel ) - qGreen( myActualPixel ) ) > pixelTolerance ||
685 std::abs( qBlue( myExpectedPixel ) - qBlue( myActualPixel ) ) > pixelTolerance ||
686 std::abs( qAlpha( myExpectedPixel ) - qAlpha( myActualPixel ) ) > pixelTolerance )
687 {
688 ++mMismatchCount;
689 diffScanline[ x ] = qRgb( 255, 0, 0 );
690 }
691 }
692 }
693 }
694
695 //
696 // Send match result to debug
697 //
698 if ( mMismatchCount > mismatchCount )
699 {
700 emitDashMessage( "Rendered Image " + testName + prefix, QgsDartMeasurement::ImagePng, mRenderedImageFile );
701 emitDashMessage( "Expected Image " + testName + prefix, QgsDartMeasurement::ImagePng, referenceImageFile );
702
703 if ( !flags.testFlag( Flag::Silent ) )
704 {
705 qDebug( "%d/%d pixels mismatched (%d allowed)", mMismatchCount, mMatchTarget, mismatchCount );
706 }
707
708 //
709 //save the diff image to disk
710 //
711 myDifferenceImage.save( mDiffImageFile );
712 emitDashMessage( "Difference Image " + testName + prefix, QgsDartMeasurement::ImagePng, mDiffImageFile );
713 }
714
715 //
716 // Send match result to report
717 //
718 mReport += u"<tr><td colspan=3>%1/%2 pixels mismatched (allowed threshold: %3, allowed color component tolerance: %4)</td></tr>"_s
719 .arg( mMismatchCount ).arg( mMatchTarget ).arg( mismatchCount ).arg( mColorTolerance );
720
721 //
722 // And send it to CDash
723 //
724 if ( mMismatchCount > 0 )
725 {
726 emitDashMessage( u"Mismatch Count"_s, QgsDartMeasurement::Integer, u"%1/%2"_s.arg( mMismatchCount ).arg( mMatchTarget ) );
727 }
728
729 if ( mMismatchCount <= mismatchCount )
730 {
731 mReport += "<tr><td colspan = 3>\n"_L1;
732 mReport += u"%1 and %2 for "_s.arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are matched<br>";
733 mReport += "</td></tr>"_L1;
734 if ( mElapsedTimeTarget != 0 && mElapsedTimeTarget < mElapsedTime )
735 {
736 //test failed because it took too long...
737 qDebug( "Test failed because render step took too long" );
738 mReport += "<tr><td colspan = 3>\n"_L1;
739 mReport += "<font color=red>Test failed because render step took too long</font>"_L1;
740 mReport += "</td></tr>"_L1;
741 mReport += myImagesString;
742
743 mMarkdownReport += "Test failed because render step took too long\n"_L1;
744
745 performPostTestActions( flags );
746 return mResult;
747 }
748 else
749 {
750 mReport += myImagesString;
751 mResult = true;
752 performPostTestActions( flags );
753 return mResult;
754 }
755 }
756
757 mReport += "<tr><td colspan=3></td></tr>"_L1;
758 emitDashMessage( u"Image mismatch"_s, QgsDartMeasurement::Text, "Difference image did not match any known anomaly or mask."
759 " If you feel the difference image should be considered an anomaly "
760 "you can do something like this\n"
761 "cp '" + mDiffImageFile + "' " + controlImagePath() + mControlName +
762 "/\nIf it should be included in the mask run\n"
763 "scripts/generate_test_mask_image.py '" + referenceImageFile + "' '" + mRenderedImageFile + "'\n" );
764
765 mReport += "<tr><td colspan = 3>\n"_L1;
766 mReport += u"<font color=red>%1 and %2 for "_s.arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are mismatched</font><br>";
767 mReport += "</td></tr>"_L1;
768 mReport += myImagesString;
769
770 const QString githubSha = qgetenv( "GITHUB_SHA" );
771 if ( !githubSha.isEmpty() )
772 {
773 const QString githubBlobUrl = u"https://github.com/qgis/QGIS/blob/%1/%2"_s.arg(
774 githubSha, QDir( sourcePath() ).relativeFilePath( referenceImageFile ) );
775 mMarkdownReport += u"Rendered image did not match [%1](%2) (found %3 pixels different)\n"_s.arg(
776 QDir( sourcePath() ).relativeFilePath( referenceImageFile ), githubBlobUrl ).arg( mMismatchCount );
777 }
778 else
779 {
780 mMarkdownReport += u"Rendered image did not match [%1](%2) (found %3 pixels different)\n"_s.arg(
781 QDir( sourcePath() ).relativeFilePath( referenceImageFile ),
782 QUrl::fromLocalFile( referenceImageFile ).toString() ).arg( mMismatchCount );
783 }
784
785 performPostTestActions( flags );
786 return mResult;
787}
@ Antialiasing
Enable anti-aliasing for map rendering.
Definition qgis.h:2775
Emits dart measurements for display in CDash reports.
void start()
Start the rendering job and immediately return.
Job implementation that renders everything sequentially in one thread.
QImage renderedImage() override
Gets a preview/resulting image.
void waitForFinished() override
Block until the job has finished.
Contains configuration for rendering maps.
A rectangle specified with double values.
double xMinimum
double yMaximum
Q_DECL_DEPRECATED bool isKnownAnomaly(const QString &diffImageFile)
Gets a list of all the anomalies.
void setControlName(const QString &name)
Sets the base directory name for the control image (with control image path suffixed).
static QDir testReportDir()
Returns the directory to use for generating a test report.
static QString sourcePath()
Returns the path to the QGIS source code.
QString markdownReport(bool ignoreSuccess=true) const
Returns the markdown report describing the results of the test run.
QString mReport
HTML format report.
static bool shouldGenerateReport()
Returns true if a test report should be generated given the current environment.
unsigned int mMatchTarget
void setControlImagePath(const QString &path)
Sets the base path containing the reference images.
void setMapSettings(const QgsMapSettings &mapSettings)
void setControlPathSuffix(const QString &name)
bool runTest(const QString &testName, unsigned int mismatchCount=0, QgsRenderChecker::Flags flags=QgsRenderChecker::Flags())
Test using renderer to generate the image to be compared.
QString imageToHash(const QString &imageFile)
Gets an md5 hash that uniquely identifies an image.
QFlags< Flag > Flags
Render checker flags.
@ Silent
Don't output non-critical messages to console.
@ AvoidExportingRenderedImage
Avoids exporting rendered images to reports.
QString mMarkdownReport
Markdown report.
QString report(bool ignoreSuccess=true) const
Returns the HTML report describing the results of the test run.
static void drawBackground(QImage *image)
Draws a checkboard pattern for image backgrounds, so that opacity is visible without requiring a tran...
bool compareImages(const QString &testName, unsigned int mismatchCount=0, const QString &renderedImageFile=QString(), QgsRenderChecker::Flags flags=QgsRenderChecker::Flags())
Test using two arbitrary images (map renderer will not be used).
QgsRenderChecker()
Constructor for QgsRenderChecker.
QString controlImagePath() const
Returns the base path containing the reference images.
unsigned int mismatchCount() const
Returns the number of pixels which did not match the control image.
QString qgsDoubleToString(double a, int precision=17)
Returns a string representation of a double.
Definition qgis.h:6817
QString qgsEnumValueToKey(const T &value, bool *returnOk=nullptr)
Returns the value for the given key of an enum.
Definition qgis.h:7091