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