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