QGIS API Documentation  2.99.0-Master (6a61179)
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...
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)
QString controlImagePath() const
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)
QgsRectangle extent() const
Return geographical coordinates of the rectangle that should be rendered.
void setOutputSize(QSize size)
Set the size of the resulting map image.
QString qgsDoubleToString(double a, int precision=17)
Returns a string representation of a double.
Definition: qgis.h:184
double mapUnitsPerPixel() const
Return the distance in geographical coordinates that equals to one pixel in the map.
Enable anti-aliasing for map rendering.
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.
double xMinimum() const
Get the x minimum value (left side of rectangle)
Definition: qgsrectangle.h:196
double yMaximum() const
Get the y maximum value (top side of rectangle)
Definition: qgsrectangle.h:201
virtual void waitForFinished() override
Block until the job has finished.