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