Files
workbuddy_skills/draw-ui/scripts/compare_mockup.py
T

101 lines
4.9 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""按原始坐标比较截图,不缩放候选图,不把误差分数当成视觉验收。"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
import re
from PIL import Image, ImageChops, ImageEnhance, ImageStat
def safe_name(value):
if not re.fullmatch(r'[\w.-]+', value) or '..' in value:
raise ValueError('名称只能包含文字、数字、短横线、下划线或单个点')
return value
def rms(diff):
stat = ImageStat.Stat(diff)
return math.sqrt(sum(value * value for value in stat.rms) / len(stat.rms))
def make_heatmap(diff):
gray = ImageEnhance.Contrast(diff.convert('L')).enhance(2.2)
gray = ImageEnhance.Brightness(gray).enhance(1.5)
heat = Image.new('RGB', diff.size, (255, 255, 255))
heat.paste(Image.new('RGB', diff.size, (255, 55, 55)), mask=gray)
return heat
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--reference', type=Path, required=True)
parser.add_argument('--candidate', type=Path, required=True)
parser.add_argument('--out-dir', type=Path, required=True)
parser.add_argument('--prefix', default='comparison')
parser.add_argument('--clip', action='append', default=[], help='名称:x,y,w,h;可重复')
parser.add_argument('--allow-height-difference', action='store_true', help='长页诊断:保留高度差,只比较共同坐标,不表示页面完整')
args = parser.parse_args()
reference = Image.open(args.reference).convert('RGB')
candidate = Image.open(args.candidate).convert('RGB')
if reference.width != candidate.width:
parser.error('截图宽度不一致;请先校准视口、DPR及截图缩放方式。不会自动缩放。')
if reference.height != candidate.height and not args.allow_height_difference:
parser.error('截图高度不一致;先修正截图方式。长页诊断可显式使用 --allow-height-difference。')
clips = []
names = {'full', 'overlap', 'reference-tail', 'candidate-tail'}
try:
safe_name(args.prefix)
for raw in args.clip:
name, numbers = raw.split(':', 1)
safe_name(name)
x, y, w, h = map(int, numbers.split(','))
if name in names or min(x, y) < 0 or min(w, h) <= 0 or x+w > reference.width or y+h > reference.height:
raise ValueError('分区重名、尺寸无效或超出参考图')
if y+h > candidate.height and not args.allow_height_difference:
raise ValueError('分区超出候选图')
names.add(name)
clips.append((name, (x, y, x+w, y+h)))
except ValueError as error:
parser.error(str(error))
args.out_dir.mkdir(parents=True, exist_ok=True)
def compare_region(name, ref, cand):
diff = ImageChops.difference(ref, cand)
paths = {suffix: args.out_dir / f'{args.prefix}-{name}-{suffix}.png' for suffix in ('candidate', 'diff', 'heatmap')}
cand.save(paths['candidate'])
diff.save(paths['diff'])
make_heatmap(diff).save(paths['heatmap'])
return {'name': name, 'size': ref.size, 'rms_diff': round(rms(diff), 3),
'mean_abs_diff': round(sum(ImageStat.Stat(diff).mean)/3, 3),
**{key: str(value) for key, value in paths.items()}}
height = min(reference.height, candidate.height)
equal = reference.size == candidate.size
box = (0, 0, reference.width, height)
regions = [compare_region('full' if equal else 'overlap', reference.crop(box), candidate.crop(box))]
tails = {}
for label, image in [('reference', reference), ('candidate', candidate)]:
if image.height > height:
path = args.out_dir / f'{args.prefix}-{label}-tail.png'
image.crop((0, height, image.width, image.height)).save(path)
tails[label] = str(path)
for name, box in clips:
if box[3] > candidate.height:
regions.append({'name': name, 'status': '候选截图缺少完整分区,未计分', 'box': box})
else:
regions.append(compare_region(name, reference.crop(box), candidate.crop(box)))
metrics = {'reference': str(args.reference), 'candidate': str(args.candidate),
'reference_size': reference.size, 'candidate_size_original': candidate.size,
'candidate_size_compared': [candidate.width, height], 'resized': False,
'geometry_match': equal, 'height_delta': candidate.height-reference.height,
'unmatched_tails': tails, 'regions': regions,
'note': '误差不等于还原度;字体、摄影和位置分别判断。尺寸一致也不代表视觉通过。'}
(args.out_dir / f'{args.prefix}-metrics.json').write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(metrics, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())