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