#!/usr/bin/env python3 """Compare a before and an after screenshot, and draw the pair for a pull request. A performance change should look exactly the same. This says whether it does: it compares the two screenshots pixel by pixel, leaving out the regions you --mask (the clock moves on), and reports how many pixels differ and where. It also writes the pair side by side, with the regions you --blur pixelated (your Wi-Fi name, an account picture), ready to attach. tools/screendiff.py shots/before.png shots/after.png --mask 90,40,900,220 \\ --blur 1265,125,1525,197 --out shots/before-after.png Boxes are x0,y0,x1,y1 in the screenshots' pixels. Exit status: 0 identical, 1 different, 2 an error. Needs Pillow (tools/requirements-perf.txt). Read tools/screendiff.md first. """ import argparse from pathlib import Path import re import sys BOX = re.compile(r"^\d+,\d+,\d+,\d+$") def box(text): """'x0,y0,x1,y1' as a tuple, x0 < x1 and y0 < y1.""" if not BOX.match(text): raise argparse.ArgumentTypeError(f"a box is x0,y0,x1,y1 in pixels, not {text!r}") x0, y0, x1, y1 = map(int, text.split(",")) if x0 >= x1 or y0 >= y1: raise argparse.ArgumentTypeError(f"a box needs x0 < x1 and y0 < y1: {text!r}") return x0, y0, x1, y1 def output_path(option, path): """The file a picture will be written to, checked before the screenshots are read. Exit status 1 is 'the two screenshots differ', a finding the reader acts on, so a folder typed wrong must not come back as one: --out and --diff are judged here, before the images are opened and before Pillow is imported, and a path that cannot be written is named rather than attempted. Left out is not the same as given empty. Both options are optional, so `--out "$OUT"` with OUT unset used to be dropped on the floor: the pair the run was asked to write was never written, nothing said so, and the run went on to report the comparison with 0 or 1 - the statuses that say the pictures were compared and, if they differ, nothing was. A name that came out empty is named here, with the way out, before anything is read. """ if path is None: return None if not path.strip(): example = "--out shots/before-after.png" if option == "--out" else "--diff shots/diff.png" raise ValueError(f"{option} was given as {path!r}, an empty name, so no picture would be written: " f"name the file, e.g. {example} - and if the name came from a " f"shell variable, that variable was empty. Leave the option out to compare without " f"writing it") target = Path(path) if target.is_dir(): raise ValueError(f"{option} {path} is a folder, so nothing could be written there: " f"name the file, e.g. --out shots/before-after.png") if not target.parent.is_dir(): if target.parent.is_file(): raise ValueError(f"{option} {path}: {target.parent} is a file, not a folder") raise ValueError(f"{option} {path}: there is no folder {target.parent} - create it first") if not target.suffix: raise ValueError(f"{option} {path} has no extension, so its format is unknown: name it {path}.png") return target def same_file(first, second): """Resolve spelling and symlink aliases, then check existing files for hardlinks.""" if first.resolve() == second.resolve(): return True try: return first.samefile(second) except FileNotFoundError: return False def write(image, path): """Save one picture, naming the path and the reason if it cannot be written. Pillow answers an unwritable destination with OSError and an extension it does not know with ValueError; either left alone ends the run in a traceback with exit 1, the status the page reads as 'the screenshots differ'. """ try: image.save(path) except (OSError, ValueError) as e: raise ValueError(f"cannot write {path}: {getattr(e, 'strerror', None) or e}") from e def screenshot(which, path): """The picture at `path`, decoded now, named as the argument it was given as. Image.open() reads only the header, so a screenshot whose copy was cut short - an interrupted `tools/tv.py --screenshot`, a file pulled off the TV before the write finished, a download that stopped early - opens here without complaint and fails on the first pixel read, inside the comparison. Left alone that is an OSError traceback with exit 1, the status the page reads as "different: N pixels differ ... find out why before posting": a finding about the launcher from a file nothing could be read out of. Decoding it here answers it as the unreadable file it is, with the before or after argument named. """ from PIL import Image image = None try: image = Image.open(path) image.load() except OSError as e: if image is not None: image.close() # the file is open; a refusal should not leave the handle for the GC raise ValueError(f"cannot read the {which} screenshot {path}: {e}. Nothing was compared.") return image def compare(a, b, masks, threshold): """(pixels that differ by more than threshold in any channel, their bounding box or None, the largest difference).""" return _compare(a, b, masks, threshold)[:3] def _compare(a, b, masks, threshold): """The comparison numbers and the binary pixel mask they describe.""" from PIL import ImageChops, ImageDraw a, b = a.convert("RGB").copy(), b.convert("RGB").copy() for im in (a, b): draw = ImageDraw.Draw(im) for m in masks: draw.rectangle([m[0], m[1], m[2] - 1, m[3] - 1], fill="black") diff = ImageChops.difference(a, b) # The largest channel difference per pixel, then a 0/255 mask of pixels over the threshold. r, g, bl = diff.split() worst = ImageChops.lighter(ImageChops.lighter(r, g), bl) over = worst.point(lambda v: 255 if v > threshold else 0) count = over.histogram()[255] return count, over.getbbox(), max(worst.getdata()), over def redact(im, region): """Pixelate then blur a region, so text in it can't be read back.""" from PIL import Image, ImageFilter part = im.crop(region) small = part.resize((max(1, part.size[0] // 14), max(1, part.size[1] // 14)), Image.BILINEAR) im.paste(small.resize(part.size, Image.NEAREST).filter(ImageFilter.GaussianBlur(4)), region) return im def pair(a, b, blurs, labels, scale): """The two screenshots side by side, blurred where asked, scaled, with a label over each.""" from PIL import Image, ImageDraw, ImageFont a, b = a.convert("RGB").copy(), b.convert("RGB").copy() for region in blurs: redact(a, region) redact(b, region) w, h = int(a.size[0] * scale), int(a.size[1] * scale) a, b = a.resize((w, h), Image.LANCZOS), b.resize((w, h), Image.LANCZOS) out = Image.new("RGB", (2 * w + 30, h + 60), "white") out.paste(a, (0, 60)) out.paste(b, (w + 30, 60)) draw, font = ImageDraw.Draw(out), ImageFont.load_default(size=30) draw.text((10, 14), labels[0], fill="black", font=font) draw.text((w + 40, 14), labels[1], fill="black", font=font) return out def parse(argv): p = argparse.ArgumentParser(description="Compare two screenshots and draw the pair. See tools/screendiff.md.") p.add_argument("before") p.add_argument("after") p.add_argument("--mask", type=box, action="append", default=[], metavar="BOX", help="leave a region out of the comparison (repeatable), e.g. the clock") p.add_argument("--blur", type=box, action="append", default=[], metavar="BOX", help="pixelate a region in the picture (repeatable), e.g. the Wi-Fi name") p.add_argument("--out", help="write the side-by-side pair here") p.add_argument("--diff", metavar="PNG", help="write the differing pixels here, white on black") p.add_argument("--labels", default="Before,After", help="labels over the pair, comma-separated") p.add_argument("--scale", type=float, default=0.5, help="size of each screenshot in the pair (default 0.5)") p.add_argument("--threshold", type=int, default=8, help="ignore channel differences up to this (0-255, default 8: compression noise)") args = p.parse_args(argv) if not 0 < args.scale <= 1: p.error("--scale is between 0 and 1") if not 0 <= args.threshold <= 255: p.error("--threshold is between 0 and 255") labels = [l.strip() for l in args.labels.split(",")] if len(labels) != 2: p.error("--labels takes two labels") args.label_pair = labels return args def main(argv=None): args = parse(sys.argv[1:] if argv is None else argv) used = [("the before screenshot", Path(args.before)), ("the after screenshot", Path(args.after))] for option, path in (("--out", args.out), ("--diff", args.diff)): try: target = output_path(option, path) if target is not None: for label, other in used: if same_file(target, other): raise ValueError(f"{option} {path} is the same file as {label} {other}") used.append((option, target)) except (ValueError, OSError, RuntimeError) as e: print(f"{e}. Nothing was read or written.", file=sys.stderr) return 2 try: import PIL # noqa: F401 - the check; screenshot() reads Image when it is about to decode except ImportError: print("screendiff.py needs Pillow: tools/venv/bin/pip install -r tools/requirements-perf.txt", file=sys.stderr) return 2 try: a, b = screenshot("before", args.before), screenshot("after", args.after) except ValueError as e: print(e, file=sys.stderr) return 2 if a.size != b.size: print(f"the screenshots differ in size: {a.size[0]}x{a.size[1]} and {b.size[0]}x{b.size[1]}", file=sys.stderr) return 2 outside = [r for r in args.mask + args.blur if r[2] > a.size[0] or r[3] > a.size[1]] if outside: print(f"a box is outside the {a.size[0]}x{a.size[1]} screenshot: {outside[0]}", file=sys.stderr) return 2 count, bbox, worst, diff = _compare(a, b, args.mask, args.threshold) if args.out: try: write(pair(a, b, args.blur, args.label_pair, args.scale), args.out) except ValueError as e: print(f"{e}. The comparison was not reported.", file=sys.stderr) return 2 print(f"{args.out}: written") if args.diff: try: write(diff, args.diff) except ValueError as e: print(f"{e}. The comparison was not reported.", file=sys.stderr) return 2 masked = f" ({len(args.mask)} region{'s' if len(args.mask) != 1 else ''} masked)" if args.mask else "" if count == 0: print(f"identical{masked}: no pixel differs by more than {args.threshold}") return 0 print(f"different{masked}: {count} pixels differ by more than {args.threshold}, inside " f"{bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]} (largest difference {worst})") return 1 if __name__ == "__main__": sys.exit(main())