QGIS API Documentation  2.18.21-Las Palmas (9fba24a)
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...
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)
QString controlImagePath() const
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
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 fromUtf8(const char *str, int size)
QString tempPath()
int width() const
QString qgsDoubleToString(double a, int precision=17)
Returns a string representation of a double.
Definition: qgis.h:341
bool isEmpty() const
double mapUnitsPerPixel() const
Return the distance in geographical coordinates that equals to one pixel in the map.
Enable anti-aliasing for map rendering.
const char * constData() const
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
char * data()
double xMinimum() const
Get the x minimum value (left side of rectangle)
Definition: qgsrectangle.h:192
void start()
int height() const
double yMaximum() const
Get the y maximum value (top side of rectangle)
Definition: qgsrectangle.h:197
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
QImage scaled(int width, int height, Qt::AspectRatioMode aspectRatioMode, Qt::TransformationMode transformMode) const
QByteArray toUtf8() const