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