QGIS API Documentation  2.99.0-Master (08ee180)
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 #include "qgsgeometry.h"
21 
22 #include <QColor>
23 #include <QPainter>
24 #include <QImage>
25 #include <QTime>
26 #include <QCryptographicHash>
27 #include <QByteArray>
28 #include <QDebug>
29 #include <QBuffer>
30 
31 static int renderCounter = 0;
32 
34  : mReport( QLatin1String( "" ) )
35  , mMatchTarget( 0 )
36  , mElapsedTime( 0 )
37  , mRenderedImageFile( QLatin1String( "" ) )
38  , mExpectedImageFile( QLatin1String( "" ) )
39  , mMismatchCount( 0 )
40  , mColorTolerance( 0 )
41  , mMaxSizeDifferenceX( 0 )
42  , mMaxSizeDifferenceY( 0 )
43  , mElapsedTimeTarget( 0 )
44  , mBufferDashMessages( false )
45 {
46 }
47 
49 {
50  QString myDataDir( TEST_DATA_DIR ); //defined in CmakeLists.txt
51  QString myControlImageDir = myDataDir + "/control_images/" + mControlPathPrefix;
52  return myControlImageDir;
53 }
54 
55 void QgsRenderChecker::setControlName( const QString &theName )
56 {
57  mControlName = theName;
58  mExpectedImageFile = controlImagePath() + theName + '/' + mControlPathSuffix + theName + ".png";
59 }
60 
61 void QgsRenderChecker::setControlPathSuffix( const QString& theName )
62 {
63  if ( !theName.isEmpty() )
64  mControlPathSuffix = theName + '/';
65  else
66  mControlPathSuffix.clear();
67 }
68 
69 QString QgsRenderChecker::imageToHash( const QString& theImageFile )
70 {
71  QImage myImage;
72  myImage.load( theImageFile );
73  QByteArray myByteArray;
74  QBuffer myBuffer( &myByteArray );
75  myImage.save( &myBuffer, "PNG" );
76  QString myImageString = QString::fromUtf8( myByteArray.toBase64().data() );
77  QCryptographicHash myHash( QCryptographicHash::Md5 );
78  myHash.addData( myImageString.toUtf8() );
79  return myHash.result().toHex().constData();
80 }
81 
83 {
84  mMapSettings = mapSettings;
85 }
86 
87 void QgsRenderChecker::drawBackground( QImage* image )
88 {
89  // create a 2x2 checker-board image
90  uchar pixDataRGB[] = { 255, 255, 255, 255,
91  127, 127, 127, 255,
92  127, 127, 127, 255,
93  255, 255, 255, 255
94  };
95 
96  QImage img( pixDataRGB, 2, 2, 8, QImage::Format_ARGB32 );
97  QPixmap pix = QPixmap::fromImage( img.scaled( 20, 20 ) );
98 
99  // fill image with texture
100  QBrush brush;
101  brush.setTexture( pix );
102  QPainter p( image );
103  p.setRenderHint( QPainter::Antialiasing, false );
104  p.fillRect( QRect( 0, 0, image->width(), image->height() ), brush );
105  p.end();
106 }
107 
108 bool QgsRenderChecker::isKnownAnomaly( const QString& theDiffImageFile )
109 {
110  QString myControlImageDir = controlImagePath() + mControlName + '/';
111  QDir myDirectory = QDir( myControlImageDir );
112  QStringList myList;
113  QString myFilename = QStringLiteral( "*" );
114  myList = myDirectory.entryList( QStringList( myFilename ),
115  QDir::Files | QDir::NoSymLinks );
116  //remove the control file from the list as the anomalies are
117  //all files except the control file
118  myList.removeAt( myList.indexOf( QFileInfo( mExpectedImageFile ).fileName() ) );
119 
120  QString myImageHash = imageToHash( theDiffImageFile );
121 
122 
123  for ( int i = 0; i < myList.size(); ++i )
124  {
125  QString myFile = myList.at( i );
126  mReport += "<tr><td colspan=3>"
127  "Checking if " + myFile + " is a known anomaly.";
128  mReport += QLatin1String( "</td></tr>" );
129  QString myAnomalyHash = imageToHash( controlImagePath() + mControlName + '/' + myFile );
130  QString myHashMessage = QStringLiteral(
131  "Checking if anomaly %1 (hash %2)<br>" )
132  .arg( myFile,
133  myAnomalyHash );
134  myHashMessage += QStringLiteral( "&nbsp; matches %1 (hash %2)" )
135  .arg( theDiffImageFile,
136  myImageHash );
137  //foo CDash
138  emitDashMessage( QStringLiteral( "Anomaly check" ), QgsDartMeasurement::Text, myHashMessage );
139 
140  mReport += "<tr><td colspan=3>" + myHashMessage + "</td></tr>";
141  if ( myImageHash == myAnomalyHash )
142  {
143  mReport += "<tr><td colspan=3>"
144  "Anomaly found! " + myFile;
145  mReport += QLatin1String( "</td></tr>" );
146  return true;
147  }
148  }
149  mReport += "<tr><td colspan=3>"
150  "No anomaly found! ";
151  mReport += QLatin1String( "</td></tr>" );
152  return false;
153 }
154 
155 void QgsRenderChecker::emitDashMessage( const QgsDartMeasurement& dashMessage )
156 {
157  if ( mBufferDashMessages )
158  mDashMessages << dashMessage;
159  else
160  dashMessage.send();
161 }
162 
163 void QgsRenderChecker::emitDashMessage( const QString& name, QgsDartMeasurement::Type type, const QString& value )
164 {
165  emitDashMessage( QgsDartMeasurement( name, type, value ) );
166 }
167 
168 bool QgsRenderChecker::runTest( const QString& theTestName,
169  unsigned int theMismatchCount )
170 {
171  if ( mExpectedImageFile.isEmpty() )
172  {
173  qDebug( "QgsRenderChecker::runTest failed - Expected Image File not set." );
174  mReport = "<table>"
175  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
176  "<tr><td>Nothing rendered</td>\n<td>Failed because Expected "
177  "Image File not set.</td></tr></table>\n";
178  return false;
179  }
180  //
181  // Load the expected result pixmap
182  //
183  QImage myExpectedImage( mExpectedImageFile );
184  if ( myExpectedImage.isNull() )
185  {
186  qDebug() << "QgsRenderChecker::runTest failed - Could not load expected image from " << mExpectedImageFile;
187  mReport = "<table>"
188  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
189  "<tr><td>Nothing rendered</td>\n<td>Failed because Expected "
190  "Image File could not be loaded.</td></tr></table>\n";
191  return false;
192  }
193  mMatchTarget = myExpectedImage.width() * myExpectedImage.height();
194  //
195  // Now render our layers onto a pixmap
196  //
197  mMapSettings.setBackgroundColor( qRgb( 152, 219, 249 ) );
198  mMapSettings.setFlag( QgsMapSettings::Antialiasing );
199  mMapSettings.setOutputSize( QSize( myExpectedImage.width(), myExpectedImage.height() ) );
200 
201  QTime myTime;
202  myTime.start();
203 
204  QgsMapRendererSequentialJob job( mMapSettings );
205  job.start();
206  job.waitForFinished();
207 
208  mElapsedTime = myTime.elapsed();
209 
210  QImage myImage = job.renderedImage();
211 
212  //
213  // Save the pixmap to disk so the user can make a
214  // visual assessment if needed
215  //
216  mRenderedImageFile = QDir::tempPath() + '/' + theTestName + "_result.png";
217 
218  myImage.setDotsPerMeterX( myExpectedImage.dotsPerMeterX() );
219  myImage.setDotsPerMeterY( myExpectedImage.dotsPerMeterY() );
220  if ( ! myImage.save( mRenderedImageFile, "PNG", 100 ) )
221  {
222  qDebug() << "QgsRenderChecker::runTest failed - Could not save rendered image to " << mRenderedImageFile;
223  mReport = "<table>"
224  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
225  "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
226  "Image File could not be saved.</td></tr></table>\n";
227  return false;
228  }
229 
230  //create a world file to go with the image...
231 
232  QFile wldFile( QDir::tempPath() + '/' + theTestName + "_result.wld" );
233  if ( wldFile.open( QIODevice::WriteOnly | QIODevice::Truncate ) )
234  {
235  QgsRectangle r = mMapSettings.extent();
236 
237  QTextStream stream( &wldFile );
238  stream << QStringLiteral( "%1\r\n0 \r\n0 \r\n%2\r\n%3\r\n%4\r\n" )
239  .arg( qgsDoubleToString( mMapSettings.mapUnitsPerPixel() ),
240  qgsDoubleToString( -mMapSettings.mapUnitsPerPixel() ),
241  qgsDoubleToString( r.xMinimum() + mMapSettings.mapUnitsPerPixel() / 2.0 ),
242  qgsDoubleToString( r.yMaximum() - mMapSettings.mapUnitsPerPixel() / 2.0 ) );
243  }
244 
245  return compareImages( theTestName, theMismatchCount );
246 }
247 
248 
249 bool QgsRenderChecker::compareImages( const QString& theTestName,
250  unsigned int theMismatchCount,
251  const QString& theRenderedImageFile )
252 {
253  if ( mExpectedImageFile.isEmpty() )
254  {
255  qDebug( "QgsRenderChecker::runTest failed - Expected Image (control) File not set." );
256  mReport = "<table>"
257  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
258  "<tr><td>Nothing rendered</td>\n<td>Failed because Expected "
259  "Image File not set.</td></tr></table>\n";
260  return false;
261  }
262  if ( ! theRenderedImageFile.isEmpty() )
263  {
264  mRenderedImageFile = theRenderedImageFile;
265 #ifdef Q_OS_WIN
266  mRenderedImageFile = mRenderedImageFile.replace( '\\', '/' );
267 #endif
268  }
269 
270  if ( mRenderedImageFile.isEmpty() )
271  {
272  qDebug( "QgsRenderChecker::runTest failed - Rendered Image File not set." );
273  mReport = "<table>"
274  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
275  "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
276  "Image File not set.</td></tr></table>\n";
277  return false;
278  }
279 
280  //
281  // Load /create the images
282  //
283  QImage myExpectedImage( mExpectedImageFile );
284  QImage myResultImage( mRenderedImageFile );
285  if ( myResultImage.isNull() )
286  {
287  qDebug() << "QgsRenderChecker::runTest failed - Could not load rendered image from " << mRenderedImageFile;
288  mReport = "<table>"
289  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
290  "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
291  "Image File could not be loaded.</td></tr></table>\n";
292  return false;
293  }
294  QImage myDifferenceImage( myExpectedImage.width(),
295  myExpectedImage.height(),
296  QImage::Format_RGB32 );
297  QString myDiffImageFile = QDir::tempPath() + '/' + theTestName + "_result_diff.png";
298  myDifferenceImage.fill( qRgb( 152, 219, 249 ) );
299 
300  //check for mask
301  QString maskImagePath = mExpectedImageFile;
302  maskImagePath.chop( 4 ); //remove .png extension
303  maskImagePath += QLatin1String( "_mask.png" );
304  QImage* maskImage = new QImage( maskImagePath );
305  bool hasMask = !maskImage->isNull();
306  if ( hasMask )
307  {
308  qDebug( "QgsRenderChecker using mask image" );
309  }
310 
311  //
312  // Set pixel count score and target
313  //
314  mMatchTarget = myExpectedImage.width() * myExpectedImage.height();
315  unsigned int myPixelCount = myResultImage.width() * myResultImage.height();
316  //
317  // Set the report with the result
318  //
319  mReport = QStringLiteral( "<script src=\"file://%1/../renderchecker.js\"></script>\n" ).arg( TEST_DATA_DIR );
320  mReport += QLatin1String( "<table>" );
321  mReport += QLatin1String( "<tr><td colspan=2>" );
322  mReport += QString( "<tr><td colspan=2>"
323  "Test image and result image for %1<br>"
324  "Expected size: %2 w x %3 h (%4 pixels)<br>"
325  "Actual size: %5 w x %6 h (%7 pixels)"
326  "</td></tr>" )
327  .arg( theTestName )
328  .arg( myExpectedImage.width() ).arg( myExpectedImage.height() ).arg( mMatchTarget )
329  .arg( myResultImage.width() ).arg( myResultImage.height() ).arg( myPixelCount );
330  mReport += QString( "<tr><td colspan=2>\n"
331  "Expected Duration : <= %1 (0 indicates not specified)<br>"
332  "Actual Duration : %2 ms<br></td></tr>" )
333  .arg( mElapsedTimeTarget )
334  .arg( mElapsedTime );
335 
336  // limit image size in page to something reasonable
337  int imgWidth = 420;
338  int imgHeight = 280;
339  if ( ! myExpectedImage.isNull() )
340  {
341  imgWidth = qMin( myExpectedImage.width(), imgWidth );
342  imgHeight = myExpectedImage.height() * imgWidth / myExpectedImage.width();
343  }
344 
345  QString myImagesString = QString(
346  "<tr>"
347  "<td colspan=2>Compare actual and expected result</td>"
348  "<td>Difference (all blue is good, any red is bad)</td>"
349  "</tr>\n<tr>"
350  "<td colspan=2 id=\"td-%1-%7\"></td>\n"
351  "<td align=center><img width=%5 height=%6 src=\"file://%2\"></td>\n"
352  "</tr>"
353  "</table>\n"
354  "<script>\naddComparison(\"td-%1-%7\",\"file://%3\",\"file://%4\",%5,%6);\n</script>\n" )
355  .arg( theTestName,
356  myDiffImageFile,
359  .arg( imgWidth ).arg( imgHeight )
360  .arg( renderCounter++ );
361 
362  QString prefix;
363  if ( !mControlPathPrefix.isNull() )
364  {
365  prefix = QStringLiteral( " (prefix %1)" ).arg( mControlPathPrefix );
366  }
367  //
368  // To get the images into CDash
369  //
370  emitDashMessage( "Rendered Image " + theTestName + prefix, QgsDartMeasurement::ImagePng, mRenderedImageFile );
371  emitDashMessage( "Expected Image " + theTestName + prefix, QgsDartMeasurement::ImagePng, mExpectedImageFile );
372 
373  //
374  // Put the same info to debug too
375  //
376 
377  qDebug( "Expected size: %dw x %dh", myExpectedImage.width(), myExpectedImage.height() );
378  qDebug( "Actual size: %dw x %dh", myResultImage.width(), myResultImage.height() );
379 
380  if ( mMatchTarget != myPixelCount )
381  {
382  qDebug( "Test image and result image for %s are different dimensions", theTestName.toLocal8Bit().constData() );
383 
384  if ( qAbs( myExpectedImage.width() - myResultImage.width() ) > mMaxSizeDifferenceX ||
385  qAbs( myExpectedImage.height() - myResultImage.height() ) > mMaxSizeDifferenceY )
386  {
387  mReport += QLatin1String( "<tr><td colspan=3>" );
388  mReport += "<font color=red>Expected image and result image for " + theTestName + " are different dimensions - FAILING!</font>";
389  mReport += QLatin1String( "</td></tr>" );
390  mReport += myImagesString;
391  delete maskImage;
392  return false;
393  }
394  else
395  {
396  mReport += QLatin1String( "<tr><td colspan=3>" );
397  mReport += "Expected image and result image for " + theTestName + " are different dimensions, but within tolerance";
398  mReport += QLatin1String( "</td></tr>" );
399  }
400  }
401 
402  //
403  // Now iterate through them counting how many
404  // dissimilar pixel values there are
405  //
406 
407  int maxHeight = qMin( myExpectedImage.height(), myResultImage.height() );
408  int maxWidth = qMin( myExpectedImage.width(), myResultImage.width() );
409 
410  mMismatchCount = 0;
411  int colorTolerance = static_cast< int >( mColorTolerance );
412  for ( int y = 0; y < maxHeight; ++y )
413  {
414  const QRgb* expectedScanline = reinterpret_cast< const QRgb* >( myExpectedImage.constScanLine( y ) );
415  const QRgb* resultScanline = reinterpret_cast< const QRgb* >( myResultImage.constScanLine( y ) );
416  const QRgb* maskScanline = hasMask ? reinterpret_cast< const QRgb* >( maskImage->constScanLine( y ) ) : nullptr;
417  QRgb* diffScanline = reinterpret_cast< QRgb* >( myDifferenceImage.scanLine( y ) );
418 
419  for ( int x = 0; x < maxWidth; ++x )
420  {
421  int maskTolerance = hasMask ? qRed( maskScanline[ x ] ) : 0;
422  int pixelTolerance = qMax( colorTolerance, maskTolerance );
423  if ( pixelTolerance == 255 )
424  {
425  //skip pixel
426  continue;
427  }
428 
429  QRgb myExpectedPixel = expectedScanline[x];
430  QRgb myActualPixel = resultScanline[x];
431  if ( pixelTolerance == 0 )
432  {
433  if ( myExpectedPixel != myActualPixel )
434  {
435  ++mMismatchCount;
436  diffScanline[ x ] = qRgb( 255, 0, 0 );
437  }
438  }
439  else
440  {
441  if ( qAbs( qRed( myExpectedPixel ) - qRed( myActualPixel ) ) > pixelTolerance ||
442  qAbs( qGreen( myExpectedPixel ) - qGreen( myActualPixel ) ) > pixelTolerance ||
443  qAbs( qBlue( myExpectedPixel ) - qBlue( myActualPixel ) ) > pixelTolerance ||
444  qAbs( qAlpha( myExpectedPixel ) - qAlpha( myActualPixel ) ) > pixelTolerance )
445  {
446  ++mMismatchCount;
447  diffScanline[ x ] = qRgb( 255, 0, 0 );
448  }
449  }
450  }
451  }
452  //
453  //save the diff image to disk
454  //
455  myDifferenceImage.save( myDiffImageFile );
456  emitDashMessage( "Difference Image " + theTestName + prefix, QgsDartMeasurement::ImagePng, myDiffImageFile );
457  delete maskImage;
458 
459  //
460  // Send match result to debug
461  //
462  qDebug( "%d/%d pixels mismatched (%d allowed)", mMismatchCount, mMatchTarget, theMismatchCount );
463 
464  //
465  // Send match result to report
466  //
467  mReport += QStringLiteral( "<tr><td colspan=3>%1/%2 pixels mismatched (allowed threshold: %3, allowed color component tolerance: %4)</td></tr>" )
468  .arg( mMismatchCount ).arg( mMatchTarget ).arg( theMismatchCount ).arg( mColorTolerance );
469 
470  //
471  // And send it to CDash
472  //
473  emitDashMessage( QStringLiteral( "Mismatch Count" ), QgsDartMeasurement::Integer, QStringLiteral( "%1/%2" ).arg( mMismatchCount ).arg( mMatchTarget ) );
474 
475  if ( mMismatchCount <= theMismatchCount )
476  {
477  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
478  mReport += "Test image and result image for " + theTestName + " are matched<br>";
479  mReport += QLatin1String( "</td></tr>" );
480  if ( mElapsedTimeTarget != 0 && mElapsedTimeTarget < mElapsedTime )
481  {
482  //test failed because it took too long...
483  qDebug( "Test failed because render step took too long" );
484  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
485  mReport += QLatin1String( "<font color=red>Test failed because render step took too long</font>" );
486  mReport += QLatin1String( "</td></tr>" );
487  mReport += myImagesString;
488  return false;
489  }
490  else
491  {
492  mReport += myImagesString;
493  return true;
494  }
495  }
496 
497  bool myAnomalyMatchFlag = isKnownAnomaly( myDiffImageFile );
498  if ( myAnomalyMatchFlag )
499  {
500  mReport += "<tr><td colspan=3>"
501  "Difference image matched a known anomaly - passing test! "
502  "</td></tr>";
503  return true;
504  }
505 
506  mReport += QLatin1String( "<tr><td colspan=3></td></tr>" );
507  emitDashMessage( QStringLiteral( "Image mismatch" ), QgsDartMeasurement::Text, "Difference image did not match any known anomaly or mask."
508  " If you feel the difference image should be considered an anomaly "
509  "you can do something like this\n"
510  "cp '" + myDiffImageFile + "' " + controlImagePath() + mControlName +
511  "/\nIf it should be included in the mask run\n"
512  "scripts/generate_test_mask_image.py '" + mExpectedImageFile + "' '" + mRenderedImageFile + "'\n" );
513 
514  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
515  mReport += "<font color=red>Test image and result image for " + theTestName + " are mismatched</font><br>";
516  mReport += QLatin1String( "</td></tr>" );
517  mReport += myImagesString;
518  return false;
519 }
void setControlPathSuffix(const QString &theName)
A rectangle specified with double values.
Definition: qgsrectangle.h:35
virtual void start() override
Start the rendering job and immediately return.
QString imageToHash(const QString &theImageFile)
Get an md5 hash that uniquely identifies an image.
void setControlName(const QString &theName)
Base directory name for the control image (with control image path suffixed) the path to the image wi...
double yMaximum() const
Get the y maximum value (top side of rectangle)
Definition: qgsrectangle.h:201
bool runTest(const QString &theTestName, unsigned int theMismatchCount=0)
Test using renderer to generate the image to be compared.
void setMapSettings(const QgsMapSettings &mapSettings)
void setFlag(Flag flag, bool on=true)
Enable or disable a particular flag (other flags are not affected)
The QgsMapSettings class contains configuration for rendering of the map.
bool compareImages(const QString &theTestName, unsigned int theMismatchCount=0, const QString &theRenderedImageFile="")
Test using two arbitary images (map renderer will not be used)
void setOutputSize(QSize size)
Set the size of the resulting map image.
QString controlImagePath() const
QString qgsDoubleToString(double a, int precision=17)
Returns a string representation of a double.
Definition: qgis.h:184
Enable anti-aliasing for map rendering.
double mapUnitsPerPixel() const
Return the distance in geographical coordinates that equals to one pixel in the map.
unsigned int mMatchTarget
static int renderCounter
static void drawBackground(QImage *image)
Draws a checkboard pattern for image backgrounds, so that transparency is visible without requiring a...
Job implementation that renders everything sequentially in one thread.
void setBackgroundColor(const QColor &color)
Set the background color of the map.
bool isKnownAnomaly(const QString &theDiffImageFile)
Get a list of all the anomalies.
virtual QImage renderedImage() override
Get a preview/resulting image.
QgsRectangle extent() const
Return geographical coordinates of the rectangle that should be rendered.
virtual void waitForFinished() override
Block until the job has finished.
double xMinimum() const
Get the x minimum value (left side of rectangle)
Definition: qgsrectangle.h:196