1#!/usr/bin/env python3
2"""Measure vertical scrolling from overlapping native screenshots."""
3import argparse
4from collections import Counter, defaultdict
5import json
6from pathlib import Path
7
8import numpy as np
9from PIL import Image
10
11
12def compare(before, after, minimum_overlap=64):
13 if (before.shape != after.shape or before.ndim != 3 or before.shape[2] != 3
14 or before.dtype != np.uint8 or after.dtype != np.uint8):
15 raise ValueError('Screenshots must have the same RGB dimensions')
16 height = before.shape[0]
17 if not 1 <= minimum_overlap <= height:
18 raise ValueError('Minimum overlap must fit inside the screenshot')
19 rows = defaultdict(list)
20 for y, row in enumerate(before):
21 if (row.min(axis=1) < 210).any():
22 rows[row.tobytes()].append(y)
23 votes = Counter()
24 for y, row in enumerate(after):
25 for old_y in rows.get(row.tobytes(), ()):
26 delta = old_y - y
27 if 0 <= delta <= height - minimum_overlap:
28 votes[delta] += 1
29 if not votes:
30 raise ValueError('No exact foreground rows establish a vertical overlap')
31 candidates = []
32 for delta, count in votes.items():
33 a, b = before[delta:], after[:height - delta]
34 foreground = (a.min(axis=2) < 210) | (b.min(axis=2) < 210)
35 equal = (a == b).all(axis=2)
36 candidates.append({
37 'scroll_y': delta, 'overlap_height': len(a), 'identical_rows': count,
38 'foreground_pixels': int(foreground.sum()),
39 'foreground_equal_fraction': float(equal[foreground].mean()),
40 'foreground_mean_absolute_error': float(
41 np.abs(a.astype(np.int16) - b.astype(np.int16))[foreground].mean()),
42 })
43 candidates.sort(key=lambda c: c['foreground_equal_fraction'], reverse=True)
44 best = candidates[0]
45 ambiguous = len(candidates) > 1 and (
46 candidates[1]['foreground_equal_fraction'] == best['foreground_equal_fraction'])
47 return {'scroll_y': None if ambiguous else best['scroll_y'],
48 'ambiguous': ambiguous, 'candidates': candidates,
49 'limits': ['Only downward integer scrolling with unchanged horizontal position is measured.',
50 'Candidates require identical foreground rows; a failure is not evidence of no scrolling.',
51 'Foreground excludes pixels whose three channels are all at least 210.']}
52
53
54if __name__ == '__main__':
55 parser = argparse.ArgumentParser(description=__doc__)
56 parser.add_argument('before', type=Path)
57 parser.add_argument('after', type=Path)
58 parser.add_argument('--crop', type=int, nargs=4, metavar=('LEFT', 'TOP', 'RIGHT', 'BOTTOM'))
59 args = parser.parse_args()
60 images = []
61 for path in (args.before, args.after):
62 with Image.open(path) as image:
63 image = image.convert('RGB')
64 if args.crop:
65 left, top, right, bottom = args.crop
66 if not (0 <= left < right <= image.width and 0 <= top < bottom <= image.height):
67 parser.error('The crop must fit inside each screenshot')
68 image = image.crop(args.crop)
69 images.append(np.asarray(image))
70 print(json.dumps(compare(*images), indent=2))