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