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