QGIS API Documentation  3.4.15-Madeira (e83d02e274)
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 #if QT_VERSION >= 0x050600
198  Q_ASSERT( myImage.devicePixelRatioF() == mMapSettings.devicePixelRatio() );
199 #endif
200 
201  //
202  // Save the pixmap to disk so the user can make a
203  // visual assessment if needed
204  //
205  mRenderedImageFile = QDir::tempPath() + '/' + testName + "_result.png";
206 
207  myImage.setDotsPerMeterX( myExpectedImage.dotsPerMeterX() );
208  myImage.setDotsPerMeterY( myExpectedImage.dotsPerMeterY() );
209  if ( ! myImage.save( mRenderedImageFile, "PNG", 100 ) )
210  {
211  qDebug() << "QgsRenderChecker::runTest failed - Could not save rendered image to " << mRenderedImageFile;
212  mReport = "<table>"
213  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
214  "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
215  "Image File could not be saved.</td></tr></table>\n";
216  return false;
217  }
218 
219  //create a world file to go with the image...
220 
221  QFile wldFile( QDir::tempPath() + '/' + testName + "_result.wld" );
222  if ( wldFile.open( QIODevice::WriteOnly | QIODevice::Truncate ) )
223  {
224  QgsRectangle r = mMapSettings.extent();
225 
226  QTextStream stream( &wldFile );
227  stream << QStringLiteral( "%1\r\n0 \r\n0 \r\n%2\r\n%3\r\n%4\r\n" )
228  .arg( qgsDoubleToString( mMapSettings.mapUnitsPerPixel() ),
229  qgsDoubleToString( -mMapSettings.mapUnitsPerPixel() ),
230  qgsDoubleToString( r.xMinimum() + mMapSettings.mapUnitsPerPixel() / 2.0 ),
231  qgsDoubleToString( r.yMaximum() - mMapSettings.mapUnitsPerPixel() / 2.0 ) );
232  }
233 
234  return compareImages( testName, mismatchCount );
235 }
236 
237 
238 bool QgsRenderChecker::compareImages( const QString &testName,
239  unsigned int mismatchCount,
240  const QString &renderedImageFile )
241 {
242  if ( mExpectedImageFile.isEmpty() )
243  {
244  qDebug( "QgsRenderChecker::runTest failed - Expected Image (control) File not set." );
245  mReport = "<table>"
246  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
247  "<tr><td>Nothing rendered</td>\n<td>Failed because Expected "
248  "Image File not set.</td></tr></table>\n";
249  return false;
250  }
251  if ( ! renderedImageFile.isEmpty() )
252  {
253  mRenderedImageFile = renderedImageFile;
254 #ifdef Q_OS_WIN
255  mRenderedImageFile = mRenderedImageFile.replace( '\\', '/' );
256 #endif
257  }
258 
259  if ( mRenderedImageFile.isEmpty() )
260  {
261  qDebug( "QgsRenderChecker::runTest failed - Rendered Image File not set." );
262  mReport = "<table>"
263  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
264  "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
265  "Image File not set.</td></tr></table>\n";
266  return false;
267  }
268 
269  //
270  // Load /create the images
271  //
272  QImage myExpectedImage( mExpectedImageFile );
273  QImage myResultImage( mRenderedImageFile );
274  if ( myResultImage.isNull() )
275  {
276  qDebug() << "QgsRenderChecker::runTest failed - Could not load rendered image from " << mRenderedImageFile;
277  mReport = "<table>"
278  "<tr><td>Test Result:</td><td>Expected Result:</td></tr>\n"
279  "<tr><td>Nothing rendered</td>\n<td>Failed because Rendered "
280  "Image File could not be loaded.</td></tr></table>\n";
281  return false;
282  }
283  QImage myDifferenceImage( myExpectedImage.width(),
284  myExpectedImage.height(),
285  QImage::Format_RGB32 );
286  QString myDiffImageFile = QDir::tempPath() + '/' + testName + "_result_diff.png";
287  myDifferenceImage.fill( qRgb( 152, 219, 249 ) );
288 
289  //check for mask
290  QString maskImagePath = mExpectedImageFile;
291  maskImagePath.chop( 4 ); //remove .png extension
292  maskImagePath += QLatin1String( "_mask.png" );
293  QImage *maskImage = new QImage( maskImagePath );
294  bool hasMask = !maskImage->isNull();
295  if ( hasMask )
296  {
297  qDebug( "QgsRenderChecker using mask image" );
298  }
299 
300  //
301  // Set pixel count score and target
302  //
303  mMatchTarget = myExpectedImage.width() * myExpectedImage.height();
304  unsigned int myPixelCount = myResultImage.width() * myResultImage.height();
305  //
306  // Set the report with the result
307  //
308  mReport = QStringLiteral( "<script src=\"file://%1/../renderchecker.js\"></script>\n" ).arg( TEST_DATA_DIR );
309  mReport += QLatin1String( "<table>" );
310  mReport += QLatin1String( "<tr><td colspan=2>" );
311  mReport += QString( "<tr><td colspan=2>"
312  "Test image and result image for %1<br>"
313  "Expected size: %2 w x %3 h (%4 pixels)<br>"
314  "Actual size: %5 w x %6 h (%7 pixels)"
315  "</td></tr>" )
316  .arg( testName )
317  .arg( myExpectedImage.width() ).arg( myExpectedImage.height() ).arg( mMatchTarget )
318  .arg( myResultImage.width() ).arg( myResultImage.height() ).arg( myPixelCount );
319  mReport += QString( "<tr><td colspan=2>\n"
320  "Expected Duration : <= %1 (0 indicates not specified)<br>"
321  "Actual Duration : %2 ms<br></td></tr>" )
322  .arg( mElapsedTimeTarget )
323  .arg( mElapsedTime );
324 
325  // limit image size in page to something reasonable
326  int imgWidth = 420;
327  int imgHeight = 280;
328  if ( ! myExpectedImage.isNull() )
329  {
330  imgWidth = std::min( myExpectedImage.width(), imgWidth );
331  imgHeight = myExpectedImage.height() * imgWidth / myExpectedImage.width();
332  }
333 
334  QString myImagesString = QString(
335  "<tr>"
336  "<td colspan=2>Compare actual and expected result</td>"
337  "<td>Difference (all blue is good, any red is bad)</td>"
338  "</tr>\n<tr>"
339  "<td colspan=2 id=\"td-%1-%7\"></td>\n"
340  "<td align=center><img width=%5 height=%6 src=\"file://%2\"></td>\n"
341  "</tr>"
342  "</table>\n"
343  "<script>\naddComparison(\"td-%1-%7\",\"file://%3\",\"file://%4\",%5,%6);\n</script>\n" )
344  .arg( testName,
345  myDiffImageFile,
348  .arg( imgWidth ).arg( imgHeight )
349  .arg( sRenderCounter++ );
350 
351  QString prefix;
352  if ( !mControlPathPrefix.isNull() )
353  {
354  prefix = QStringLiteral( " (prefix %1)" ).arg( mControlPathPrefix );
355  }
356  //
357  // To get the images into CDash
358  //
359  emitDashMessage( "Rendered Image " + testName + prefix, QgsDartMeasurement::ImagePng, mRenderedImageFile );
360  emitDashMessage( "Expected Image " + testName + prefix, QgsDartMeasurement::ImagePng, mExpectedImageFile );
361 
362  //
363  // Put the same info to debug too
364  //
365 
366  qDebug( "Expected size: %dw x %dh", myExpectedImage.width(), myExpectedImage.height() );
367  qDebug( "Actual size: %dw x %dh", myResultImage.width(), myResultImage.height() );
368 
369  if ( mMatchTarget != myPixelCount )
370  {
371  qDebug( "Test image and result image for %s are different dimensions", testName.toLocal8Bit().constData() );
372 
373  if ( std::abs( myExpectedImage.width() - myResultImage.width() ) > mMaxSizeDifferenceX ||
374  std::abs( myExpectedImage.height() - myResultImage.height() ) > mMaxSizeDifferenceY )
375  {
376  mReport += QLatin1String( "<tr><td colspan=3>" );
377  mReport += "<font color=red>Expected image and result image for " + testName + " are different dimensions - FAILING!</font>";
378  mReport += QLatin1String( "</td></tr>" );
379  mReport += myImagesString;
380  delete maskImage;
381  return false;
382  }
383  else
384  {
385  mReport += QLatin1String( "<tr><td colspan=3>" );
386  mReport += "Expected image and result image for " + testName + " are different dimensions, but within tolerance";
387  mReport += QLatin1String( "</td></tr>" );
388  }
389  }
390 
391  if ( myExpectedImage.format() == QImage::Format_Indexed8 )
392  {
393  if ( myResultImage.format() != QImage::Format_Indexed8 )
394  {
395  qDebug() << "Expected image and result image for " << testName << " have different formats (8bit format is expected) - FAILING!";
396 
397  mReport += QLatin1String( "<tr><td colspan=3>" );
398  mReport += "<font color=red>Expected image and result image for " + testName + " have different formats (8bit format is expected) - FAILING!</font>";
399  mReport += QLatin1String( "</td></tr>" );
400  mReport += myImagesString;
401  delete maskImage;
402  return false;
403  }
404 
405  // When we compute the diff between the 2 images, we use constScanLine expecting a QRgb color
406  // but this method returns color table index for 8 bit image, not color.
407  // So we convert the 2 images in 32 bits so the diff works correctly
408  myResultImage = myResultImage.convertToFormat( QImage::Format_ARGB32 );
409  myExpectedImage = myExpectedImage.convertToFormat( QImage::Format_ARGB32 );
410  }
411 
412 
413  //
414  // Now iterate through them counting how many
415  // dissimilar pixel values there are
416  //
417 
418  int maxHeight = std::min( myExpectedImage.height(), myResultImage.height() );
419  int maxWidth = std::min( myExpectedImage.width(), myResultImage.width() );
420 
421  mMismatchCount = 0;
422  int colorTolerance = static_cast< int >( mColorTolerance );
423  for ( int y = 0; y < maxHeight; ++y )
424  {
425  const QRgb *expectedScanline = reinterpret_cast< const QRgb * >( myExpectedImage.constScanLine( y ) );
426  const QRgb *resultScanline = reinterpret_cast< const QRgb * >( myResultImage.constScanLine( y ) );
427  const QRgb *maskScanline = hasMask ? reinterpret_cast< const QRgb * >( maskImage->constScanLine( y ) ) : nullptr;
428  QRgb *diffScanline = reinterpret_cast< QRgb * >( myDifferenceImage.scanLine( y ) );
429 
430  for ( int x = 0; x < maxWidth; ++x )
431  {
432  int maskTolerance = hasMask ? qRed( maskScanline[ x ] ) : 0;
433  int pixelTolerance = std::max( colorTolerance, maskTolerance );
434  if ( pixelTolerance == 255 )
435  {
436  //skip pixel
437  continue;
438  }
439 
440  QRgb myExpectedPixel = expectedScanline[x];
441  QRgb myActualPixel = resultScanline[x];
442  if ( pixelTolerance == 0 )
443  {
444  if ( myExpectedPixel != myActualPixel )
445  {
446  ++mMismatchCount;
447  diffScanline[ x ] = qRgb( 255, 0, 0 );
448  }
449  }
450  else
451  {
452  if ( std::abs( qRed( myExpectedPixel ) - qRed( myActualPixel ) ) > pixelTolerance ||
453  std::abs( qGreen( myExpectedPixel ) - qGreen( myActualPixel ) ) > pixelTolerance ||
454  std::abs( qBlue( myExpectedPixel ) - qBlue( myActualPixel ) ) > pixelTolerance ||
455  std::abs( qAlpha( myExpectedPixel ) - qAlpha( myActualPixel ) ) > pixelTolerance )
456  {
457  ++mMismatchCount;
458  diffScanline[ x ] = qRgb( 255, 0, 0 );
459  }
460  }
461  }
462  }
463  //
464  //save the diff image to disk
465  //
466  myDifferenceImage.save( myDiffImageFile );
467  emitDashMessage( "Difference Image " + testName + prefix, QgsDartMeasurement::ImagePng, myDiffImageFile );
468  delete maskImage;
469 
470  //
471  // Send match result to debug
472  //
473  qDebug( "%d/%d pixels mismatched (%d allowed)", mMismatchCount, mMatchTarget, mismatchCount );
474 
475  //
476  // Send match result to report
477  //
478  mReport += QStringLiteral( "<tr><td colspan=3>%1/%2 pixels mismatched (allowed threshold: %3, allowed color component tolerance: %4)</td></tr>" )
479  .arg( mMismatchCount ).arg( mMatchTarget ).arg( mismatchCount ).arg( mColorTolerance );
480 
481  //
482  // And send it to CDash
483  //
484  emitDashMessage( QStringLiteral( "Mismatch Count" ), QgsDartMeasurement::Integer, QStringLiteral( "%1/%2" ).arg( mMismatchCount ).arg( mMatchTarget ) );
485 
486  if ( mMismatchCount <= mismatchCount )
487  {
488  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
489  mReport += "Test image and result image for " + testName + " are matched<br>";
490  mReport += QLatin1String( "</td></tr>" );
491  if ( mElapsedTimeTarget != 0 && mElapsedTimeTarget < mElapsedTime )
492  {
493  //test failed because it took too long...
494  qDebug( "Test failed because render step took too long" );
495  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
496  mReport += QLatin1String( "<font color=red>Test failed because render step took too long</font>" );
497  mReport += QLatin1String( "</td></tr>" );
498  mReport += myImagesString;
499  return false;
500  }
501  else
502  {
503  mReport += myImagesString;
504  return true;
505  }
506  }
507 
508  bool myAnomalyMatchFlag = isKnownAnomaly( myDiffImageFile );
509  if ( myAnomalyMatchFlag )
510  {
511  mReport += "<tr><td colspan=3>"
512  "Difference image matched a known anomaly - passing test! "
513  "</td></tr>";
514  return true;
515  }
516 
517  mReport += QLatin1String( "<tr><td colspan=3></td></tr>" );
518  emitDashMessage( QStringLiteral( "Image mismatch" ), QgsDartMeasurement::Text, "Difference image did not match any known anomaly or mask."
519  " If you feel the difference image should be considered an anomaly "
520  "you can do something like this\n"
521  "cp '" + myDiffImageFile + "' " + controlImagePath() + mControlName +
522  "/\nIf it should be included in the mask run\n"
523  "scripts/generate_test_mask_image.py '" + mExpectedImageFile + "' '" + mRenderedImageFile + "'\n" );
524 
525  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
526  mReport += "<font color=red>Test image and result image for " + testName + " are mismatched</font><br>";
527  mReport += QLatin1String( "</td></tr>" );
528  mReport += myImagesString;
529  return false;
530 }
A rectangle specified with double values.
Definition: qgsrectangle.h:40
void start() override
Start the rendering job and immediately return.
double yMaximum() const
Returns the y maximum value (top side of rectangle).
Definition: qgsrectangle.h:171
float devicePixelRatio() const
Returns device pixel ratio Common values are 1 for normal-dpi displays and 2 for high-dpi "retina" di...
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.
void setControlName(const QString &name)
Base directory name for the control image (with control image path suffixed) the path to the image wi...
void setOutputSize(QSize size)
Sets 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:238
Enable anti-aliasing for map rendering.
double mapUnitsPerPixel() const
Returns the distance in geographical coordinates that equals to one pixel in the map.
bool isKnownAnomaly(const QString &diffImageFile)
Gets a list of all the anomalies.
unsigned int mMatchTarget
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.
QgsRectangle extent() const
Returns geographical coordinates of the rectangle that should be rendered.
unsigned int mismatchCount()
void waitForFinished() override
Block until the job has finished.
void setControlPathSuffix(const QString &name)
double xMinimum() const
Returns the x minimum value (left side of rectangle).
Definition: qgsrectangle.h:166