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