QGIS API Documentation  3.10.0-A Coruña (6c816b4204)
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  if ( myExpectedImage.format() == QImage::Format_Indexed8 )
390  {
391  if ( myResultImage.format() != QImage::Format_Indexed8 )
392  {
393  qDebug() << "Expected image and result image for " << testName << " have different formats (8bit format is expected) - FAILING!";
394 
395  mReport += QLatin1String( "<tr><td colspan=3>" );
396  mReport += "<font color=red>Expected image and result image for " + testName + " have different formats (8bit format is expected) - FAILING!</font>";
397  mReport += QLatin1String( "</td></tr>" );
398  mReport += myImagesString;
399  delete maskImage;
400  return false;
401  }
402 
403  // When we compute the diff between the 2 images, we use constScanLine expecting a QRgb color
404  // but this method returns color table index for 8 bit image, not color.
405  // So we convert the 2 images in 32 bits so the diff works correctly
406  myResultImage = myResultImage.convertToFormat( QImage::Format_ARGB32 );
407  myExpectedImage = myExpectedImage.convertToFormat( QImage::Format_ARGB32 );
408  }
409 
410 
411  //
412  // Now iterate through them counting how many
413  // dissimilar pixel values there are
414  //
415 
416  int maxHeight = std::min( myExpectedImage.height(), myResultImage.height() );
417  int maxWidth = std::min( myExpectedImage.width(), myResultImage.width() );
418 
419  mMismatchCount = 0;
420  int colorTolerance = static_cast< int >( mColorTolerance );
421  for ( int y = 0; y < maxHeight; ++y )
422  {
423  const QRgb *expectedScanline = reinterpret_cast< const QRgb * >( myExpectedImage.constScanLine( y ) );
424  const QRgb *resultScanline = reinterpret_cast< const QRgb * >( myResultImage.constScanLine( y ) );
425  const QRgb *maskScanline = hasMask ? reinterpret_cast< const QRgb * >( maskImage->constScanLine( y ) ) : nullptr;
426  QRgb *diffScanline = reinterpret_cast< QRgb * >( myDifferenceImage.scanLine( y ) );
427 
428  for ( int x = 0; x < maxWidth; ++x )
429  {
430  int maskTolerance = hasMask ? qRed( maskScanline[ x ] ) : 0;
431  int pixelTolerance = std::max( colorTolerance, maskTolerance );
432  if ( pixelTolerance == 255 )
433  {
434  //skip pixel
435  continue;
436  }
437 
438  QRgb myExpectedPixel = expectedScanline[x];
439  QRgb myActualPixel = resultScanline[x];
440  if ( pixelTolerance == 0 )
441  {
442  if ( myExpectedPixel != myActualPixel )
443  {
444  ++mMismatchCount;
445  diffScanline[ x ] = qRgb( 255, 0, 0 );
446  }
447  }
448  else
449  {
450  if ( std::abs( qRed( myExpectedPixel ) - qRed( myActualPixel ) ) > pixelTolerance ||
451  std::abs( qGreen( myExpectedPixel ) - qGreen( myActualPixel ) ) > pixelTolerance ||
452  std::abs( qBlue( myExpectedPixel ) - qBlue( myActualPixel ) ) > pixelTolerance ||
453  std::abs( qAlpha( myExpectedPixel ) - qAlpha( myActualPixel ) ) > pixelTolerance )
454  {
455  ++mMismatchCount;
456  diffScanline[ x ] = qRgb( 255, 0, 0 );
457  }
458  }
459  }
460  }
461  //
462  //save the diff image to disk
463  //
464  myDifferenceImage.save( myDiffImageFile );
465  emitDashMessage( "Difference Image " + testName + prefix, QgsDartMeasurement::ImagePng, myDiffImageFile );
466  delete maskImage;
467 
468  //
469  // Send match result to debug
470  //
471  qDebug( "%d/%d pixels mismatched (%d allowed)", mMismatchCount, mMatchTarget, mismatchCount );
472 
473  //
474  // Send match result to report
475  //
476  mReport += QStringLiteral( "<tr><td colspan=3>%1/%2 pixels mismatched (allowed threshold: %3, allowed color component tolerance: %4)</td></tr>" )
477  .arg( mMismatchCount ).arg( mMatchTarget ).arg( mismatchCount ).arg( mColorTolerance );
478 
479  //
480  // And send it to CDash
481  //
482  emitDashMessage( QStringLiteral( "Mismatch Count" ), QgsDartMeasurement::Integer, QStringLiteral( "%1/%2" ).arg( mMismatchCount ).arg( mMatchTarget ) );
483 
484  if ( mMismatchCount <= mismatchCount )
485  {
486  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
487  mReport += "Test image and result image for " + testName + " are matched<br>";
488  mReport += QLatin1String( "</td></tr>" );
489  if ( mElapsedTimeTarget != 0 && mElapsedTimeTarget < mElapsedTime )
490  {
491  //test failed because it took too long...
492  qDebug( "Test failed because render step took too long" );
493  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
494  mReport += QLatin1String( "<font color=red>Test failed because render step took too long</font>" );
495  mReport += QLatin1String( "</td></tr>" );
496  mReport += myImagesString;
497  return false;
498  }
499  else
500  {
501  mReport += myImagesString;
502  return true;
503  }
504  }
505 
506  bool myAnomalyMatchFlag = isKnownAnomaly( myDiffImageFile );
507  if ( myAnomalyMatchFlag )
508  {
509  mReport += "<tr><td colspan=3>"
510  "Difference image matched a known anomaly - passing test! "
511  "</td></tr>";
512  return true;
513  }
514 
515  mReport += QLatin1String( "<tr><td colspan=3></td></tr>" );
516  emitDashMessage( QStringLiteral( "Image mismatch" ), QgsDartMeasurement::Text, "Difference image did not match any known anomaly or mask."
517  " If you feel the difference image should be considered an anomaly "
518  "you can do something like this\n"
519  "cp '" + myDiffImageFile + "' " + controlImagePath() + mControlName +
520  "/\nIf it should be included in the mask run\n"
521  "scripts/generate_test_mask_image.py '" + mExpectedImageFile + "' '" + mRenderedImageFile + "'\n" );
522 
523  mReport += QLatin1String( "<tr><td colspan = 3>\n" );
524  mReport += "<font color=red>Test image and result image for " + testName + " are mismatched</font><br>";
525  mReport += QLatin1String( "</td></tr>" );
526  mReport += myImagesString;
527  return false;
528 }
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:240
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)