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