}
}
+void SkDiffContext::setDifferenceDir(const SkString& path) {
+ if (!path.isEmpty() && sk_mkdir(path.c_str())) {
+ fDifferenceDir = path;
+ }
+}
+
void SkDiffContext::setDiffers(const SkTDArray<SkImageDiffer*>& differs) {
// Delete whatever the last array of differs was
if (NULL != fDiffers) {
differs.copy(fDiffers);
}
+static SkString get_common_prefix(const SkString& a, const SkString& b) {
+ const size_t maxPrefixLength = SkTMin(a.size(), b.size());
+ SkASSERT(maxPrefixLength > 0);
+ for (size_t x = 0; x < maxPrefixLength; ++x) {
+ if (a[x] != b[x]) {
+ SkString result;
+ result.set(a.c_str(), x);
+ return result;
+ }
+ }
+ if (a.size() > b.size()) {
+ return b;
+ } else {
+ return a;
+ }
+}
+
void SkDiffContext::addDiff(const char* baselinePath, const char* testPath) {
// Load the images at the paths
SkBitmap baselineBitmap;
newRecord->fNext = fRecords;
fRecords = newRecord;
+ // compute the common name
+ SkString baseName = SkOSPath::SkBasename(baselinePath);
+ SkString testName = SkOSPath::SkBasename(testPath);
+ newRecord->fCommonName = get_common_prefix(baseName, testName);
+
+ bool alphaMaskPending = false;
+ bool alphaMaskCreated = false;
+
// Perform each diff
for (int differIndex = 0; differIndex < fDifferCount; differIndex++) {
SkImageDiffer* differ = fDiffers[differIndex];
+ // TODO only enable for one differ
+ if (!alphaMaskCreated && !fDifferenceDir.isEmpty()) {
+ alphaMaskPending = differ->enablePOIAlphaMask();
+ }
int diffID = differ->queueDiff(&baselineBitmap, &testBitmap);
if (diffID >= 0) {
SkIPoint* poi = differ->getPointsOfInterest(diffID);
diffData.fPointsOfInterest.append(poiCount, poi);
+ if (alphaMaskPending
+ && SkImageDiffer::RESULT_CORRECT != diffData.fResult
+ && newRecord->fDifferencePath.isEmpty()) {
+ newRecord->fDifferencePath = SkOSPath::SkPathJoin(fDifferenceDir.c_str(),
+ newRecord->fCommonName.c_str());
+
+ // compute the image diff and output it
+ SkBitmap* alphaMask = differ->getPointsOfInterestAlphaMask(diffID);
+ SkBitmap copy;
+ alphaMask->copyTo(©, SkBitmap::kARGB_8888_Config);
+ SkImageEncoder::EncodeFile(newRecord->fDifferencePath.c_str(), copy,
+ SkImageEncoder::kPNG_Type, 100);
+ }
+
+ if (alphaMaskPending) {
+ alphaMaskPending = false;
+ alphaMaskCreated = true;
+ }
+
// Because we are doing everything synchronously for now, we are done with the diff
// after reading it.
differ->deleteDiff(diffID);
SkString baselineAbsPath = get_absolute_path(currentRecord->fBaselinePath);
SkString testAbsPath = get_absolute_path(currentRecord->fTestPath);
- // strip off directory structure and find the common part of the filename
- SkString baseName = SkOSPath::SkBasename(baselineAbsPath.c_str());
- SkString testName = SkOSPath::SkBasename(testAbsPath.c_str());
- for (size_t x = 0; x < baseName.size(); ++x) {
- if (baseName[x] != testName[x]) {
- baseName.insertUnichar(x, '\n');
- break;
- }
- }
-
stream.writeText(" \"commonName\": \"");
- stream.writeText(baseName.c_str());
+ stream.writeText(currentRecord->fCommonName.c_str());
stream.writeText("\",\n");
stream.writeText(" \"differencePath\": \"");
void setThreadCount(int threadCount) { fThreadCount = threadCount; }
+ /**
+ * Creates the directory if it does not exist and uses it to store differences
+ * between images.
+ */
+ void setDifferenceDir(const SkString& directory);
+
/**
* Sets the differs to be used in each diff. Already started diffs will not retroactively use
* these.
};
struct DiffRecord {
+ SkString fCommonName;
SkString fDifferencePath;
SkString fBaselinePath;
SkString fTestPath;
SkImageDiffer** fDiffers;
int fDifferCount;
int fThreadCount;
+
+ SkString fDifferenceDir;
};
#endif
public SkImageDiffer {
#endif
public:
+ SkDifferentPixelsMetric() : fPOIAlphaMask(false) {}
+
virtual const char* getName() SK_OVERRIDE;
+ virtual bool enablePOIAlphaMask() SK_OVERRIDE;
virtual int queueDiff(SkBitmap* baseline, SkBitmap* test) SK_OVERRIDE;
virtual void deleteDiff(int id) SK_OVERRIDE;
virtual bool isFinished(int id) SK_OVERRIDE;
virtual double getResult(int id) SK_OVERRIDE;
virtual int getPointsOfInterestCount(int id) SK_OVERRIDE;
virtual SkIPoint* getPointsOfInterest(int id) SK_OVERRIDE;
+ virtual SkBitmap* getPointsOfInterestAlphaMask(int id) SK_OVERRIDE;
protected:
#if SK_SUPPORT_OPENCL
#endif
private:
- struct QueuedDiff;
+ bool fPOIAlphaMask;
+ struct QueuedDiff;
SkTDArray<QueuedDiff> fQueuedDiffs;
#if SK_SUPPORT_OPENCL
bool finished;
double result;
SkTDArray<SkIPoint>* poi;
+ SkBitmap poiAlphaMask;
};
const char* SkDifferentPixelsMetric::getName() {
return "different_pixels";
}
+bool SkDifferentPixelsMetric::enablePOIAlphaMask() {
+ fPOIAlphaMask = true;
+ return true;
+}
+
int SkDifferentPixelsMetric::queueDiff(SkBitmap* baseline, SkBitmap* test) {
double startTime = get_seconds();
int diffID = fQueuedDiffs.count();
int height = baseline->height();
int differentPixelsCount = 0;
+ // Prepare the POI alpha mask if needed
+ if (fPOIAlphaMask) {
+ diff->poiAlphaMask.setConfig(SkBitmap::kA8_Config, width, height);
+ diff->poiAlphaMask.allocPixels();
+ diff->poiAlphaMask.lockPixels();
+ diff->poiAlphaMask.eraseARGB(SK_AlphaOPAQUE, 0, 0, 0);
+ }
+
// Prepare the pixels for comparison
baseline->lockPixels();
test->lockPixels();
if (std::memcmp(&baselineRow[x * 4], &testRow[x * 4], 4) != 0) {
poi->push()->set(x, y);
differentPixelsCount++;
+ if (fPOIAlphaMask) {
+ *diff->poiAlphaMask.getAddr8(x,y) = SK_AlphaTRANSPARENT;
+ }
}
}
}
SkIPoint* SkDifferentPixelsMetric::getPointsOfInterest(int id) {
return fQueuedDiffs[id].poi->begin();
}
+
+SkBitmap* SkDifferentPixelsMetric::getPointsOfInterestAlphaMask(int id) {
+ if (fQueuedDiffs[id].poiAlphaMask.empty()) {
+ return NULL;
+ }
+ return &fQueuedDiffs[id].poiAlphaMask;
+}
return "different_pixels";
}
+bool SkDifferentPixelsMetric::enablePOIAlphaMask() {
+ return false;
+}
+
int SkDifferentPixelsMetric::queueDiff(SkBitmap* baseline, SkBitmap* test) {
int diffID = fQueuedDiffs.count();
double startTime = get_seconds();
SkImageDiffer();
virtual ~SkImageDiffer();
+ static const double RESULT_CORRECT = 1.0f;
+ static const double RESULT_INCORRECT = 0.0f;
+
/**
* Gets a unique and descriptive name of this differ
* @return A statically allocated null terminated string that is the name of this differ
*/
virtual bool requiresOpenCL() { return false; }
+ /**
+ * Enables the generation of an alpha mask for all points of interest.
+ * @return True if the differ supports generating an alpha mask and false otherwise.
+ */
+ virtual bool enablePOIAlphaMask() { return false; }
+
/**
* Wraps a call to queueDiff by loading the given filenames into SkBitmaps
* @param baseline The file path of the baseline image
*/
virtual SkIPoint* getPointsOfInterest(int id) = 0;
+ /*
+ * Gets a bitmap containing an alpha mask containing transparent pixels at the points of
+ * interest for the diff of the given id. The results are only meaningful after the
+ * queued diff has finished.
+ * @param id The id of the queued diff to query
+ */
+ virtual SkBitmap* getPointsOfInterestAlphaMask(int id) { return NULL; }
protected:
var MAX_SWAP_IMG_SIZE = 400;
+var MAGNIFIER_WIDTH = 200;
+var MAGNIFIER_HEIGHT = 200;
+var MAGNIFIER_HALF_WIDTH = MAGNIFIER_WIDTH * 0.5;
+var MAGNIFIER_HALF_HEIGHT = MAGNIFIER_HEIGHT * 0.5;
+// TODO add support for a magnified scale factor
+var MAGNIFIER_SCALE_FACTOR = 2.0;
angular.module('diff_viewer', []).
-config(['$routeProvider', function($routeProvider) {
- // Show the list of differences by default
- $routeProvider.
- otherwise({ templateUrl: '/diff_list.html', controller: DiffListController});
-}]).
-directive('swapImg', function() {
- // Custom directive for showing an image that gets swapped my mouseover.
- return {
- restrict: 'E', // The directive can be used as an element name
- replace: true, // The directive replaces itself with the template
- template: '<canvas ng-mouseenter="swap()" ng-mouseleave="swap()"></canvas>',
- scope: { // The attributes below are bound to the scope
- leftSrc: '@',
- rightSrc: '@',
- side: '@'
- },
- link: function(scope, elm, attrs, ctrl) {
- var leftImage = new Image();
- var rightImage = new Image();
- var ctx = elm[0].getContext('2d');
-
- scope.render = function() {
- var image;
- if (scope.side == "left") {
- image = leftImage;
- } else {
- image = rightImage;
- }
-
- // Make it so the maximum size of an image is MAX_SWAP_IMG_SIZE, and the images are
- // scaled down in halves.
- var divisor = 1;
- while ((image.width / divisor) > MAX_SWAP_IMG_SIZE) {
- divisor *= 2;
- }
-
- // Set canvas to correct size and draw the image into it
- elm[0].width = image.width / divisor;
- elm[0].height = image.height / divisor;
- ctx.drawImage(image, 0, 0, elm[0].width, elm[0].height);
- };
+directive('imgCompare', function() {
+ // Custom directive for comparing (3-way) images
+ return {
+ restrict: 'E', // The directive can be used as an element name
+ replace: true, // The directive replaces itself with the template
+ template: '<canvas/>',
+ scope: true,
+ link: function(scope, elm, attrs, ctrl) {
+ var image = new Image();
+ var canvas = elm[0];
+ var ctx = canvas.getContext('2d');
+
+ var magnifyContent = false;
+
+ // When the type attribute changes, load the image and then render
+ attrs.$observe('type', function(value) {
+ switch(value) {
+ case "alphaMask":
+ image.src = scope.record.differencePath;
+ break;
+ case "baseline":
+ image.src = scope.record.baselinePath;
+ magnifyContent = true;
+ break;
+ case "test":
+ image.src = scope.record.testPath;
+ magnifyContent = true;
+ break;
+ default:
+ console.log("Unknown type attribute on <img-compare>: " + value);
+ return;
+ }
+
+ image.onload = function() {
+ // compute the scaled image width/height for image and canvas
+ var divisor = 1;
+ // Make it so the maximum size of an image is MAX_SWAP_IMG_SIZE,
+ // and the images are scaled down in halves.
+ while ((image.width / divisor) > MAX_SWAP_IMG_SIZE) {
+ divisor *= 2;
+ }
+
+ scope.setImgScaleFactor(1 / divisor);
+
+ // Set canvas to correct size
+ canvas.width = image.width * scope.imgScaleFactor;
+ canvas.height = image.height * scope.imgScaleFactor;
+
+ // render the image onto the canvas
+ scope.renderImage();
+ }
+ });
+
+ // When the magnify attribute changes, render the magnified rect at
+ // the default zoom level.
+ scope.$watch('magnifyCenter', function(magCenter) {
+ if (!magnifyContent) {
+ return;
+ }
+
+ scope.renderImage();
+
+ if (!magCenter) {
+ return;
+ }
+
+ var magX = magCenter.x - MAGNIFIER_HALF_WIDTH;
+ var magY = magCenter.y - MAGNIFIER_HALF_HEIGHT;
+
+ var magMaxX = canvas.width - MAGNIFIER_WIDTH;
+ var magMaxY = canvas.height - MAGNIFIER_HEIGHT;
+
+ var magRect = { x: Math.max(0, Math.min(magX, magMaxX)),
+ y: Math.max(0, Math.min(magY, magMaxY)),
+ width: MAGNIFIER_WIDTH,
+ height: MAGNIFIER_HEIGHT
+ };
+
+ var imgRect = { x: (magCenter.x / scope.imgScaleFactor) - MAGNIFIER_HALF_WIDTH,
+ y: (magCenter.y / scope.imgScaleFactor) - MAGNIFIER_HALF_HEIGHT,
+ width: MAGNIFIER_WIDTH,
+ height: MAGNIFIER_HEIGHT
+ };
+
+ // draw the magnified image
+ ctx.clearRect(magRect.x, magRect.y, magRect.width, magRect.height);
+ ctx.drawImage(image, imgRect.x, imgRect.y, imgRect.width, imgRect.height,
+ magRect.x, magRect.y, magRect.width, magRect.height);
- // When the leftSrc attribute changes, load the image and then rerender
- attrs.$observe('leftSrc', function(value) {
- leftImage.src = value;
- leftImage.onload = function() {
- if (scope.side == "left") {
- scope.render();
- }
- };
- });
-
- // When the rightSrc attribute changes, load the image and then rerender
- attrs.$observe('rightSrc', function(value) {
- rightImage.src = value;
- rightImage.onload = function() {
- if (scope.side == "right") {
- scope.render();
- }
- };
- });
-
- // Swap which side to draw onto the canvas and then rerender
- scope.swap = function() {
- if (scope.side == "left") {
- scope.side = "right";
- } else {
- scope.side = "left";
- }
- scope.render();
+ // draw the outline rect
+ ctx.beginPath();
+ ctx.rect(magRect.x, magRect.y, magRect.width, magRect.height);
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = 'red';
+ ctx.stroke();
+
+ });
+
+ // render the image to the canvas. This is often done every frame prior
+ // to any special effects (i.e. magnification).
+ scope.renderImage = function() {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
+ };
+
+ // compute a rect (x,y,width,height) that represents the bounding box for
+ // the magnification effect
+ scope.computeMagnifierOutline = function(event) {
+ var scaledWidth = MAGNIFIER_WIDTH * scope.imgScaleFactor;
+ var scaledHeight = MAGNIFIER_HEIGHT * scope.imgScaleFactor;
+ return {
+ x: event.offsetX - (scaledWidth * 0.5),
+ y: event.offsetY - (scaledHeight * 0.5),
+ width: scaledWidth,
+ height: scaledHeight
};
- }
- };
+ };
+
+ // event handler for mouse events that triggers the magnification
+ // effect across the 3 images being compared.
+ scope.MagnifyDraw = function(event, startMagnify) {
+ if (startMagnify) {
+ scope.setMagnifierState(true);
+ } else if (!scope.magnifierOn) {
+ return;
+ }
+
+ scope.renderImage();
+
+ // render the magnifier outline rect
+ var rect = scope.computeMagnifierOutline(event);
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(rect.x, rect.y, rect.width, rect.height);
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = 'red';
+ ctx.stroke();
+ ctx.restore();
+
+ // update scope on baseline / test that will cause them to render
+ scope.setMagnifyCenter({x: event.offsetX, y: event.offsetY});
+ };
+
+ // event handler that triggers the end of the magnification effect and
+ // resets all the canvases to their original state.
+ scope.MagnifyEnd = function(event) {
+ scope.renderImage();
+ // update scope on baseline / test that will cause them to render
+ scope.setMagnifierState(false);
+ scope.setMagnifyCenter(undefined);
+ };
+ }
+ };
});
+function ImageController($scope, $http, $location, $timeout, $parse) {
+ $scope.imgScaleFactor = 1.0;
+ $scope.magnifierOn = false;
+ $scope.magnifyCenter = undefined;
+
+ $scope.setImgScaleFactor = function(scaleFactor) {
+ $scope.imgScaleFactor = scaleFactor;
+ }
+
+ $scope.setMagnifierState = function(magnifierOn) {
+ $scope.magnifierOn = magnifierOn;
+ }
+
+ $scope.setMagnifyCenter = function(magnifyCenter) {
+ $scope.magnifyCenter = magnifyCenter;
+ }
+}
+
function DiffListController($scope, $http, $location, $timeout, $parse) {
// Detect if we are running the web server version of the viewer. If so, we set a flag and
// enable some extra functionality of the website for rebaselining.
DEFINE_string2(folders, f, "", "Compare two folders with identical subfile names: <baseline folder> <test folder>");
DEFINE_string2(patterns, p, "", "Use two patterns to compare images: <baseline> <test>");
DEFINE_string2(output, o, "", "Writes the output of these diffs to output: <output>");
+DEFINE_string(alphaDir, "", "Writes the alpha mask of these diffs to output: <output>");
DEFINE_bool(jsonp, true, "Output JSON with padding");
DEFINE_string(csv, "", "Writes the output of these diffs to a csv file");
DEFINE_int32(threads, -1, "run N threads in parallel [default is derived from CPUs available]");
}
}
+ if (!FLAGS_alphaDir.isEmpty()) {
+ if (1 != FLAGS_alphaDir.count()) {
+ SkDebugf("alphaDir flag expects one argument: <directory>\n");
+ return 1;
+ }
+ }
+
SkDiffContext ctx;
ctx.setDiffers(chosenDiffers);
+ if (!FLAGS_alphaDir.isEmpty()) {
+ ctx.setDifferenceDir(SkString(FLAGS_alphaDir[0]));
+ }
+
if (FLAGS_threads >= 0) {
ctx.setThreadCount(FLAGS_threads);
}
<link rel="stylesheet" type="text/css" href="viewer_style.css">
<title>SkPDiff</title>
</head>
- <body>
- <!--
- All templates are being included with the main page to avoid using AJAX, which would force
- us to use a webserver.
- -->
- <script type="text/ng-template" id="/diff_list.html">
- <div ng-class="statusClass">
- <!-- The commit button -->
- <div ng-show="isDynamic" class="commit">
- <button ng-click="commitRebaselines()">Commit</button>
- </div>
- <!-- Give a choice of how to order the differs -->
- <div style="margin:8px">
- Show me the worst by metric:
- <button ng-repeat="differ in differs" ng-click="setSortIndex($index)">
- <span class="result-{{ $index }}" style="padding-left:12px;"> </span>
- {{ differ.title }}
- </button>
- </div>
- <!-- Begin list of differences -->
- <table>
- <thead>
- <tr>
- <td ng-show="isDynamic">Rebaseline?</td>
- <td>Name</td>
- <td>Expected Image</td>
- <td>Actual Image</td>
- <td>Results</td>
- </tr>
- </thead>
- <tbody>
- <!--
- Loops through every record and crates a row for it. This sorts based on the whichever
- metric the user chose, and places a limit on the max number of records to show.
- -->
- <tr ng-repeat="record in records | orderBy:sortingDiffer | limitTo:500"
- ng-class="{selected: record.isRebaselined}">
- <td ng-show="isDynamic">
- <input type="checkbox"
- ng-model="record.isRebaselined"
- ng-click="selectedRebaseline($index, $event)"
- ng-class="{lastselected: lastSelectedIndex == $index}" />
- </td>
- <td class="common-name">{{ record.commonName }}</td>
- <td>
- <swap-img left-src="{{ record.baselinePath }}"
- right-src="{{ record.testPath }}"
- side="left"
- class="gm-image left-image" /></td>
- <td>
- <swap-img left-src="{{ record.baselinePath }}"
- right-src="{{ record.testPath }}"
- side="right"
- class="gm-image right-image" /></td>
- <td>
- <div ng-repeat="diff in record.diffs"
- ng-mouseover="highlight(diff.differName)"
- class="result result-{{$index}}">
- <span class="result-button">{{ diff.result }}</span>
- </div>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- </script>
- <!-- Whatever template is used is rendered in the following div -->
- <div ng-view></div>
+ <body ng-controller="DiffListController" ng-class="statusClass">
+ <!-- The commit button -->
+ <div ng-show="isDynamic" class="commit">
+ <button ng-click="commitRebaselines()">Commit</button>
+ </div>
+ <!-- Give a choice of how to order the differs -->
+ <div style="margin:8px">
+ Show me the worst by metric:
+ <button ng-repeat="differ in differs" ng-click="setSortIndex($index)">
+ <span class="result-{{ $index }}" style="padding-left:12px;"> </span>
+ {{ differ.title }}
+ </button>
+ </div>
+ <!-- Begin list of differences -->
+ <table>
+ <thead>
+ <tr>
+ <td ng-show="isDynamic">Rebaseline?</td>
+ <td>Name</td>
+ <td>Difference Mask</td>
+ <td>Expected Image</td>
+ <td>Actual Image</td>
+ <td>Results</td>
+ </tr>
+ </thead>
+ <tbody>
+ <!--
+ Loops through every record and crates a row for it. This sorts based on the whichever
+ metric the user chose, and places a limit on the max number of records to show.
+ -->
+ <tr ng-repeat="record in records | orderBy:sortingDiffer | limitTo:500"
+ ng-class="{selected: record.isRebaselined}"
+ ng-controller="ImageController">
+ <td ng-show="isDynamic">
+ <input type="checkbox"
+ ng-model="record.isRebaselined"
+ ng-click="selectedRebaseline($index, $event)"
+ ng-class="{lastselected: lastSelectedIndex == $index}" />
+ </td>
+ <td class="common-name">{{ record.commonName }}</td>
+ <td>
+ <img-compare type="alphaMask"
+ class="left-image"
+ ng-mousedown="MagnifyDraw($event, true)"
+ ng-mousemove="MagnifyDraw($event, false)"
+ ng-mouseup="MagnifyEnd($event)"
+ ng-mouseleave="MagnifyEnd($event)"/>
+ </td>
+ <td>
+ <img-compare type="baseline"
+ name="{{record.commonName}}"
+ class="gm-image left-image" />
+ </td>
+ <td>
+ <img-compare type="test"
+ name="{{record.commonName}}"
+ class="gm-image right-image" />
+ </td>
+ <td>
+ <div ng-repeat="diff in record.diffs"
+ ng-mouseover="highlight(diff.differName)"
+ class="result result-{{$index}}">
+ <span class="result-button">{{ diff.result }}</span>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
</body>
</html>