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