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