blob: fef621c724385cecc45ee1fb50e45f063dc44a90 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.6
import 'dart:io' as io;
import 'package:image/image.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'package:yaml/yaml.dart';
import 'environment.dart';
import 'utils.dart';
/// How to compares pixels within the image.
///
/// Keep this enum in sync with the one defined in `golden_tester.dart`.
enum PixelComparison {
/// Allows minor blur and anti-aliasing differences by comparing a 3x3 grid
/// surrounding the pixel rather than direct 1:1 comparison.
fuzzy,
/// Compares one pixel at a time.
///
/// Anti-aliasing or blur will result in higher diff rate.
precise,
}
void main(List<String> args) {
final io.File fileA = io.File(args[0]);
final io.File fileB = io.File(args[1]);
final Image imageA = decodeNamedImage(fileA.readAsBytesSync(), 'a.png');
final Image imageB = decodeNamedImage(fileB.readAsBytesSync(), 'b.png');
final ImageDiff diff = ImageDiff(golden: imageA, other: imageB, pixelComparison: PixelComparison.fuzzy);
print('Diff: ${(diff.rate * 100).toStringAsFixed(4)}%');
}
/// This class encapsulates visually diffing an Image with any other.
/// Both images need to be the exact same size.
class ImageDiff {
/// The image to match
final Image golden;
/// The image being compared
final Image other;
/// Algorithm used for comparing pixels.
final PixelComparison pixelComparison;
/// The output of the comparison
/// Pixels in the output image can have 3 different colors depending on the comparison
/// between golden pixels and other pixels:
/// * white: when both pixels are the same
/// * red: when a pixel is found in other, but not in golden
/// * green: when a pixel is found in golden, but not in other
Image diff;
/// The ratio of wrong pixels to all pixels in golden (between 0 and 1)
/// This gets set to 1 (100% difference) when golden and other aren't the same size.
double get rate => _wrongPixels / _pixelCount;
ImageDiff({
@required this.golden,
@required this.other,
@required this.pixelComparison,
}) {
_computeDiff();
}
int _pixelCount = 0;
int _wrongPixels = 0;
/// That would be the distance between black and white.
static final double _maxTheoreticalColorDistance = Color.distance(
<num>[255, 255, 255], // white
<num>[0, 0, 0], // black
false,
).toDouble();
// If the normalized color difference of a pixel is greater than this number,
// we consider it a wrong pixel.
static const double _kColorDistanceThreshold = 0.1;
final int _colorOk = Color.fromRgb(255, 255, 255);
final int _colorBadPixel = Color.fromRgb(255, 0, 0);
final int _colorExpectedPixel = Color.fromRgb(0, 255, 0);
/// Reads a pixel value out of [image] at [x] and [y].
///
/// If the pixel is out of bounds, reflects the [x] and [y] coordinates off
/// the border back into the image treating the border like a mirror.
static int _reflectedPixel(Image image, int x, int y) {
x = x.abs();
if (x == image.width) {
x = image.width - 2;
}
y = y.abs();
if (y == image.height) {
y = image.height - 2;
}
return image.getPixel(x, y);
}
static int _average(Iterable<int> values) {
return values.reduce((a, b) => a + b) ~/ values.length;
}
/// The value of the pixel at [x] and [y] coordinates.
///
/// If [pixelComparison] is [PixelComparison.precise], reads the RGB value of
/// the pixel.
///
/// If [pixelComparison] is [PixelComparison.fuzzy], reads the RGB values of
/// the average of the 3x3 box of pixels centered at [x] and [y].
List<int> _getPixelRgbForComparison(Image image, int x, int y) {
switch (pixelComparison) {
case PixelComparison.fuzzy:
final List<int> pixels = <int>[
_reflectedPixel(image, x - 1, y - 1),
_reflectedPixel(image, x - 1, y),
_reflectedPixel(image, x - 1, y + 1),
_reflectedPixel(image, x, y - 1),
_reflectedPixel(image, x, y),
_reflectedPixel(image, x, y + 1),
_reflectedPixel(image, x + 1, y - 1),
_reflectedPixel(image, x + 1, y),
_reflectedPixel(image, x + 1, y + 1),
];
return <int>[
_average(pixels.map((p) => getRed(p))),
_average(pixels.map((p) => getGreen(p))),
_average(pixels.map((p) => getBlue(p))),
];
case PixelComparison.precise:
final int pixel = image.getPixel(x, y);
return <int>[
getRed(pixel),
getGreen(pixel),
getBlue(pixel),
];
default:
throw 'Unrecognized pixel comparison value: ${pixelComparison}';
}
}
void _computeDiff() {
int goldenWidth = golden.width;
int goldenHeight = golden.height;
_pixelCount = goldenWidth * goldenHeight;
diff = Image(goldenWidth, goldenHeight);
if (goldenWidth == other.width && goldenHeight == other.height) {
for(int y = 0; y < goldenHeight; y++) {
for (int x = 0; x < goldenWidth; x++) {
final bool isExactlySame = golden.getPixel(x, y) == other.getPixel(x, y);
final List<int> goldenPixel = _getPixelRgbForComparison(golden, x, y);
final List<int> otherPixel = _getPixelRgbForComparison(other, x, y);
final double colorDistance = Color.distance(goldenPixel, otherPixel, false) / _maxTheoreticalColorDistance;
final bool isFuzzySame = colorDistance < _kColorDistanceThreshold;
if (isExactlySame || isFuzzySame) {
diff.setPixel(x, y, _colorOk);
} else {
final int goldenLuminance = getLuminanceRgb(goldenPixel[0], goldenPixel[1], goldenPixel[2]);
final int otherLuminance = getLuminanceRgb(otherPixel[0], otherPixel[1], otherPixel[2]);
if (goldenLuminance < otherLuminance) {
diff.setPixel(x, y, _colorExpectedPixel);
} else {
diff.setPixel(x, y, _colorBadPixel);
}
_wrongPixels++;
}
}
}
} else {
// Images are completely different resolutions. Bail out big time.
_wrongPixels = _pixelCount;
}
}
}
// Returns text explaining pixel difference rate.
String getPrintableDiffFilesInfo(double diffRate, double maxRate) =>
'(${((diffRate) * 100).toStringAsFixed(4)}% of pixels were different. '
'Maximum allowed rate is: ${(maxRate * 100).toStringAsFixed(4)}%).';
/// Fetches golden files from github.com/flutter/goldens, cloning the repository if necessary.
///
/// The repository is cloned into web_ui/.dart_tool.
Future<void> fetchGoldens() async {
await _GoldensRepoFetcher().fetch();
}
class _GoldensRepoFetcher {
String _repository;
String _revision;
Future<void> fetch() async {
final io.File lockFile = io.File(
path.join(environment.webUiDevDir.path, 'goldens_lock.yaml')
);
final YamlMap lock = loadYaml(lockFile.readAsStringSync()) as YamlMap;
_repository = lock['repository'] as String;
_revision = lock['revision'] as String;
final String localRevision = await _getLocalRevision();
if (localRevision == _revision) {
return;
}
print('Fetching $_repository@$_revision');
if (!environment.webUiGoldensRepositoryDirectory.existsSync()) {
environment.webUiGoldensRepositoryDirectory.createSync(recursive: true);
await runProcess(
'git',
<String>['init'],
workingDirectory: environment.webUiGoldensRepositoryDirectory.path,
mustSucceed: true,
);
await runProcess(
'git',
<String>['remote', 'add', 'origin', _repository],
workingDirectory: environment.webUiGoldensRepositoryDirectory.path,
mustSucceed: true,
);
}
await runProcess(
'git',
<String>['fetch', 'origin', 'master'],
workingDirectory: environment.webUiGoldensRepositoryDirectory.path,
mustSucceed: true,
);
await runProcess(
'git',
<String>['checkout', _revision],
workingDirectory: environment.webUiGoldensRepositoryDirectory.path,
mustSucceed: true,
);
}
Future<String> _getLocalRevision() async {
final io.File head = io.File(path.join(
environment.webUiGoldensRepositoryDirectory.path, '.git', 'HEAD'
));
if (!head.existsSync()) {
return null;
}
return head.readAsStringSync().trim();
}
}