blob: f67efc880e07da5e30cbc6dd4df6396da1fea62d [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.
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';
/// 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
late 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;
/// Image diff constructor which requires two [Image]s to compare and
/// [PixelComparison] algorithm.
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() {
final int goldenWidth = golden.width;
final 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)}%).';
/// Downloads the repository that stores the golden files.
///
/// Reads the url of the repo and `commit no` to sync to, from
/// `goldens_lock.yaml`.
class GoldensRepoFetcher {
final io.Directory _webUiGoldensRepositoryDirectory;
final String _lockFilePath;
/// Constructor that takes directory to download the repository and
/// file with goldens repo information.
GoldensRepoFetcher(this._webUiGoldensRepositoryDirectory, this._lockFilePath);
/// Fetches golden files from github.com/flutter/goldens, cloning the
/// repository if necessary.
///
/// The repository is cloned into web_ui/.dart_tool.
Future<void> fetch() async {
final io.File lockFile = io.File(path.join(_lockFilePath));
final YamlMap lock = loadYaml(lockFile.readAsStringSync()) as YamlMap;
final String repository = lock['repository'] as String;
final String revision = lock['revision'] as String;
final String? localRevision = await _getLocalRevision();
if (localRevision == revision) {
return;
}
print('Fetching $repository@$revision');
if (!_webUiGoldensRepositoryDirectory.existsSync()) {
_webUiGoldensRepositoryDirectory.createSync(recursive: true);
await _runGit(
<String>['init'],
_webUiGoldensRepositoryDirectory.path,
);
await _runGit(
<String>['remote', 'add', 'origin', repository],
_webUiGoldensRepositoryDirectory.path,
);
}
await _runGit(
<String>['fetch', 'origin', 'master'],
_webUiGoldensRepositoryDirectory.path,
);
await _runGit(
<String>['checkout', revision],
_webUiGoldensRepositoryDirectory.path,
);
}
Future<String?> _getLocalRevision() async {
final io.File head = io.File(
path.join(_webUiGoldensRepositoryDirectory.path, '.git', 'HEAD'));
if (!head.existsSync()) {
return null;
}
return head.readAsStringSync().trim();
}
/// Runs `git` with given arguments.
Future<void> _runGit(
List<String> arguments,
String workingDirectory,
) async {
final io.Process process = await io.Process.start(
'git',
arguments,
workingDirectory: workingDirectory,
// Running the process in a system shell for Windows. Otherwise
// the process is not able to get Dart from path.
runInShell: io.Platform.isWindows,
mode: io.ProcessStartMode.inheritStdio,
);
final int exitCode = await process.exitCode;
if (exitCode != 0) {
throw Exception('Git command failed with arguments $arguments on '
'workingDirectory: $workingDirectory resulting with exitCode: '
'$exitCode');
}
}
}