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