QGIS API Documentation 3.40.0-Bratislava (b56115d8743)
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 ( !flags.testFlag( Flag::Silent )
527 && ( expectedImage.width() != myResultImage.width() || expectedImage.height() != myResultImage.height() )
528 )
529 {
530 qDebug( "Expected size: %dw x %dh", expectedImage.width(), expectedImage.height() );
531 qDebug( "Actual size: %dw x %dh", myResultImage.width(), myResultImage.height() );
532 if ( hasMask )
533 qDebug( "Mask size: %dw x %dh", maskImage.width(), maskImage.height() );
534 }
535
536 if ( mMatchTarget != myPixelCount )
537 {
538 if ( !flags.testFlag( Flag::Silent ) )
539 {
540 qDebug( "Expected image and rendered image for %s are different dimensions", testName.toLocal8Bit().constData() );
541 }
542
543 if ( std::abs( expectedImage.width() - myResultImage.width() ) > mMaxSizeDifferenceX ||
544 std::abs( expectedImage.height() - myResultImage.height() ) > mMaxSizeDifferenceY )
545 {
546 emitDashMessage( "Rendered Image " + testName + prefix, QgsDartMeasurement::ImagePng, mRenderedImageFile );
547 emitDashMessage( "Expected Image " + testName + prefix, QgsDartMeasurement::ImagePng, referenceImageFile );
548
549 mReport += QLatin1String( "<tr><td colspan=3>" );
550 mReport += QStringLiteral( "<font color=red>%1 and %2 for " ).arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are different dimensions - FAILING!</font>";
551 mReport += QLatin1String( "</td></tr>" );
552 mMarkdownReport += QStringLiteral( "Failed because rendered image and expected image are different dimensions (%1x%2 v2 %3x%4)\n" )
553 .arg( myResultImage.width() )
554 .arg( myResultImage.height() )
555 .arg( expectedImage.width() )
556 .arg( expectedImage.height() );
557
558 const QString diffSizeImagesString = QString(
559 "<tr>"
560 "<td colspan=3>Compare %5 and %6</td>"
561 "</tr>\n<tr>"
562 "<td align=center><img width=%3 height=%4 src=\"%2\"></td>\n"
563 "<td align=center><img src=\"%1\"></td>\n"
564 "</tr>"
565 "</table>\n" )
566 .arg(
567 renderedImageFileName,
568 referenceImageFile )
569 .arg( imgWidth ).arg( imgHeight )
570 .arg( expectedImageString, renderedImageString );
571
572 mReport += diffSizeImagesString;
573 performPostTestActions( flags );
574 return mResult;
575 }
576 else
577 {
578 mReport += QLatin1String( "<tr><td colspan=3>" );
579 mReport += QStringLiteral( "%1 and %2 for " ).arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are different dimensions, but within tolerance";
580 mReport += QLatin1String( "</td></tr>" );
581 }
582 }
583
584 if ( expectedImage.format() == QImage::Format_Indexed8 )
585 {
586 if ( myResultImage.format() != QImage::Format_Indexed8 )
587 {
588 emitDashMessage( "Rendered Image " + testName + prefix, QgsDartMeasurement::ImagePng, mRenderedImageFile );
589 emitDashMessage( "Expected Image " + testName + prefix, QgsDartMeasurement::ImagePng, referenceImageFile );
590
591 qDebug() << "Expected image and rendered image for " << testName << " have different formats (8bit format is expected) - FAILING!";
592
593 mReport += QLatin1String( "<tr><td colspan=3>" );
594 mReport += "<font color=red>Expected image and rendered image for " + testName + " have different formats (8bit format is expected) - FAILING!</font>";
595 mReport += QLatin1String( "</td></tr>" );
596 mReport += myImagesString;
597
598 mMarkdownReport += QLatin1String( "Failed because rendered image and expected image have different formats (8bit format is expected)\n" );
599 performPostTestActions( flags );
600 return mResult;
601 }
602
603 // When we compute the diff between the 2 images, we use constScanLine expecting a QRgb color
604 // but this method returns color table index for 8 bit image, not color.
605 // So we convert the 2 images in 32 bits so the diff works correctly
606 myResultImage = myResultImage.convertToFormat( QImage::Format_ARGB32 );
607 expectedImage = expectedImage.convertToFormat( QImage::Format_ARGB32 );
608 }
609 if ( expectedImage.format() != QImage::Format_RGB32
610 && expectedImage.format() != QImage::Format_ARGB32
611 && expectedImage.format() != QImage::Format_ARGB32_Premultiplied )
612 {
613 mReport += QLatin1String( "<tr><td colspan=3>" );
614 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() ) );
615 mReport += QLatin1String( "</td></tr>" );
616 mReport += myImagesString;
617
618 mMarkdownReport += QStringLiteral( "Failed because expected image has an incompatible format - %1 (32 bit format is expected)\n" ).arg( qgsEnumValueToKey( expectedImage.format() ) );
619 performPostTestActions( flags );
620 return mResult;
621 }
622 if ( myResultImage.format() != QImage::Format_RGB32
623 && myResultImage.format() != QImage::Format_ARGB32
624 && myResultImage.format() != QImage::Format_ARGB32_Premultiplied )
625 {
626 mReport += QLatin1String( "<tr><td colspan=3>" );
627 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() ) );
628 mReport += QLatin1String( "</td></tr>" );
629 mReport += myImagesString;
630
631 mMarkdownReport += QStringLiteral( "Failed because rendered image has an incompatible format - %1 (32 bit format is expected)\n" ).arg( qgsEnumValueToKey( myResultImage.format() ) );
632 performPostTestActions( flags );
633 return mResult;
634 }
635
636 //
637 // Now iterate through them counting how many
638 // dissimilar pixel values there are
639 //
640
641 const int maxHeight = std::min( expectedImage.height(), myResultImage.height() );
642 const int maxWidth = std::min( expectedImage.width(), myResultImage.width() );
643
644 const int maskWidth = maskImage.width();
645
646 mMismatchCount = 0;
647 const int colorTolerance = static_cast< int >( mColorTolerance );
648 for ( int y = 0; y < maxHeight; ++y )
649 {
650 const QRgb *expectedScanline = reinterpret_cast< const QRgb * >( expectedImage.constScanLine( y ) );
651 const QRgb *resultScanline = reinterpret_cast< const QRgb * >( myResultImage.constScanLine( y ) );
652 const QRgb *maskScanline = ( hasMask && maskImage.height() > y ) ? reinterpret_cast< const QRgb * >( maskImage.constScanLine( y ) ) : nullptr;
653 QRgb *diffScanline = reinterpret_cast< QRgb * >( myDifferenceImage.scanLine( y ) );
654
655 for ( int x = 0; x < maxWidth; ++x )
656 {
657 const int pixelTolerance = maskScanline
658 ? std::max( colorTolerance, ( maskWidth > x ) ? qRed( maskScanline[ x ] ) : 0 )
659 : colorTolerance;
660 if ( pixelTolerance == 255 )
661 {
662 //skip pixel
663 continue;
664 }
665
666 const QRgb myExpectedPixel = expectedScanline[x];
667 const QRgb myActualPixel = resultScanline[x];
668 if ( pixelTolerance == 0 )
669 {
670 if ( myExpectedPixel != myActualPixel )
671 {
672 ++mMismatchCount;
673 diffScanline[ x ] = qRgb( 255, 0, 0 );
674 }
675 }
676 else
677 {
678 if ( std::abs( qRed( myExpectedPixel ) - qRed( myActualPixel ) ) > pixelTolerance ||
679 std::abs( qGreen( myExpectedPixel ) - qGreen( myActualPixel ) ) > pixelTolerance ||
680 std::abs( qBlue( myExpectedPixel ) - qBlue( myActualPixel ) ) > pixelTolerance ||
681 std::abs( qAlpha( myExpectedPixel ) - qAlpha( myActualPixel ) ) > pixelTolerance )
682 {
683 ++mMismatchCount;
684 diffScanline[ x ] = qRgb( 255, 0, 0 );
685 }
686 }
687 }
688 }
689
690 //
691 // Send match result to debug
692 //
693 if ( mMismatchCount > mismatchCount )
694 {
695 emitDashMessage( "Rendered Image " + testName + prefix, QgsDartMeasurement::ImagePng, mRenderedImageFile );
696 emitDashMessage( "Expected Image " + testName + prefix, QgsDartMeasurement::ImagePng, referenceImageFile );
697
698 if ( !flags.testFlag( Flag::Silent ) )
699 {
700 qDebug( "%d/%d pixels mismatched (%d allowed)", mMismatchCount, mMatchTarget, mismatchCount );
701 }
702
703 //
704 //save the diff image to disk
705 //
706 myDifferenceImage.save( mDiffImageFile );
707 emitDashMessage( "Difference Image " + testName + prefix, QgsDartMeasurement::ImagePng, mDiffImageFile );
708 }
709
710 //
711 // Send match result to report
712 //
713 mReport += QStringLiteral( "<tr><td colspan=3>%1/%2 pixels mismatched (allowed threshold: %3, allowed color component tolerance: %4)</td></tr>" )
714 .arg( mMismatchCount ).arg( mMatchTarget ).arg( mismatchCount ).arg( mColorTolerance );
715
716 //
717 // And send it to CDash
718 //
719 if ( mMismatchCount > 0 )
720 {
721 emitDashMessage( QStringLiteral( "Mismatch Count" ), QgsDartMeasurement::Integer, QStringLiteral( "%1/%2" ).arg( mMismatchCount ).arg( mMatchTarget ) );
722 }
723
724 if ( mMismatchCount <= mismatchCount )
725 {
726 mReport += QLatin1String( "<tr><td colspan = 3>\n" );
727 mReport += QStringLiteral( "%1 and %2 for " ).arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are matched<br>";
728 mReport += QLatin1String( "</td></tr>" );
729 if ( mElapsedTimeTarget != 0 && mElapsedTimeTarget < mElapsedTime )
730 {
731 //test failed because it took too long...
732 qDebug( "Test failed because render step took too long" );
733 mReport += QLatin1String( "<tr><td colspan = 3>\n" );
734 mReport += QLatin1String( "<font color=red>Test failed because render step took too long</font>" );
735 mReport += QLatin1String( "</td></tr>" );
736 mReport += myImagesString;
737
738 mMarkdownReport += QLatin1String( "Test failed because render step took too long\n" );
739
740 performPostTestActions( flags );
741 return mResult;
742 }
743 else
744 {
745 mReport += myImagesString;
746 mResult = true;
747 performPostTestActions( flags );
748 return mResult;
749 }
750 }
751
752 mReport += QLatin1String( "<tr><td colspan=3></td></tr>" );
753 emitDashMessage( QStringLiteral( "Image mismatch" ), QgsDartMeasurement::Text, "Difference image did not match any known anomaly or mask."
754 " If you feel the difference image should be considered an anomaly "
755 "you can do something like this\n"
756 "cp '" + mDiffImageFile + "' " + controlImagePath() + mControlName +
757 "/\nIf it should be included in the mask run\n"
758 "scripts/generate_test_mask_image.py '" + referenceImageFile + "' '" + mRenderedImageFile + "'\n" );
759
760 mReport += QLatin1String( "<tr><td colspan = 3>\n" );
761 mReport += QStringLiteral( "<font color=red>%1 and %2 for " ).arg( upperFirst( expectedImageString ), renderedImageString ) + testName + " are mismatched</font><br>";
762 mReport += QLatin1String( "</td></tr>" );
763 mReport += myImagesString;
764
765 const QString githubSha = qgetenv( "GITHUB_SHA" );
766 if ( !githubSha.isEmpty() )
767 {
768 const QString githubBlobUrl = QStringLiteral( "https://github.com/qgis/QGIS/blob/%1/%2" ).arg(
769 githubSha, QDir( sourcePath() ).relativeFilePath( referenceImageFile ) );
770 mMarkdownReport += QStringLiteral( "Rendered image did not match [%1](%2) (found %3 pixels different)\n" ).arg(
771 QDir( sourcePath() ).relativeFilePath( referenceImageFile ), githubBlobUrl ).arg( mMismatchCount );
772 }
773 else
774 {
775 mMarkdownReport += QStringLiteral( "Rendered image did not match [%1](%2) (found %3 pixels different)\n" ).arg(
776 QDir( sourcePath() ).relativeFilePath( referenceImageFile ),
777 QUrl::fromLocalFile( referenceImageFile ).toString() ).arg( mMismatchCount );
778 }
779
780 performPostTestActions( flags );
781 return mResult;
782}
@ 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.
@ Silent
Don't output non-critical messages to console.
@ AvoidExportingRenderedImage
Avoids exporting rendered images to reports.
QString mMarkdownReport
Markdown report.
QString report(bool ignoreSuccess=true) const
Returns the HTML report describing the results of the test run.
static void drawBackground(QImage *image)
Draws a checkboard pattern for image backgrounds, so that opacity is visible without requiring a tran...
bool compareImages(const QString &testName, unsigned int mismatchCount=0, const QString &renderedImageFile=QString(), QgsRenderChecker::Flags flags=QgsRenderChecker::Flags())
Test using two arbitrary images (map renderer will not be used)
QgsRenderChecker()
Constructor for QgsRenderChecker.
QString controlImagePath() const
Returns the base path containing the reference images.
unsigned int mismatchCount() const
Returns the number of pixels which did not match the control image.
QString qgsDoubleToString(double a, int precision=17)
Returns a string representation of a double.
Definition qgis.h:5834
QString qgsEnumValueToKey(const T &value, bool *returnOk=nullptr)
Returns the value for the given key of an enum.
Definition qgis.h:6108