| 1 | #!/usr/bin/env python3 |
| 2 | """Measure vertical scrolling from overlapping native screenshots.""" |
| 3 | import argparse |
| 4 | from collections import Counter, defaultdict |
| 5 | import json |
| 6 | from pathlib import Path |
| 7 | |
| 8 | import numpy as np |
| 9 | from PIL import Image |
| 10 | |
| 11 | |
| 12 | def 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 | |
| 54 | if __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)) |