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

156 lines
9.0 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""测量已标注的参考图区域,输出原始几何、局部颜色、重复网格和阈值扫描;不猜测原始 CSS。"""
from __future__ import annotations
import argparse
import base64
import hashlib
import html
import json
import math
from pathlib import Path
from statistics import median
from PIL import Image
def positive(value, name):
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
raise ValueError(f'{name} 必须是有限正数')
return value
def integer(value, name):
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f'{name} 必须是整数')
return value
def box_checked(box, size):
if not isinstance(box, list) or len(box) != 4:
raise ValueError('box 必须为 [x,y,width,height]')
x, y, w, h = [integer(v, 'box 坐标') for v in box]
if min(x, y) < 0 or min(w, h) <= 0 or x+w > size[0] or y+h > size[1]:
raise ValueError('标注越界或面积无效;不自动裁短')
return x, y, w, h
def distribution(values):
values = sorted(values)
def quantile(p):
pos = (len(values)-1)*p
lo, hi = math.floor(pos), math.ceil(pos)
return round(values[lo] + (values[hi]-values[lo])*(pos-lo), 3)
return {'median': quantile(.5), 'p10': quantile(.1), 'p90': quantile(.9)}
def measure(manifest_path, out):
data = json.loads(manifest_path.read_text(encoding='utf-8'))
source = (manifest_path.parent / data['reference']).resolve()
with Image.open(source) as original:
im = original.convert('RGBA')
css_width = data.get('css_viewport_width')
factor = positive(css_width, 'css_viewport_width')/im.width if css_width is not None else None
regions = []
names = set()
def take_name(item):
name = item['name']
if not isinstance(name, str) or not name.strip() or name in names:
raise ValueError('标注名称必须非空且唯一')
names.add(name)
return name
for item in data.get('regions', []):
name = take_name(item)
kind = item.get('kind', 'box')
if kind not in ('box', 'color'):
raise ValueError('kind 只支持 box 或 color')
x,y,w,h = box_checked(item['box'], im.size)
box_type = item.get('box_type', 'sample' if kind == 'color' else 'element')
if box_type not in ('element', 'ink', 'sample') or (kind == 'color' and box_type != 'sample'):
raise ValueError('box_type 必须为 element、ink 或 sample;颜色区只能为 sample')
region = {'name': name, 'kind':kind, 'box_type':box_type, 'box':[x,y,w,h],
'normalized_box':[round(x/im.width,6),round(y/im.height,6),round(w/im.width,6),round(h/im.height,6)],
'boundary_evidence':'Agent 选择的标注范围,非自动识别的真实元素边界'}
if factor is not None:
region['css_box'] = [round(v*factor,3) for v in [x,y,w,h]]
if 'selector' in item: region['selector'] = item['selector']
region['dom_comparable'] = box_type == 'element'
if 'group' in item:
if box_type != 'element': raise ValueError('重复网格只接受元素框,不能混用字形框或采样框')
if not isinstance(item['group'],str) or not item['group'].strip(): raise ValueError('group 必须是非空字符串')
region['group'] = item['group']
if kind == 'color':
inset=integer(item.get('inset',0),'inset')
if inset < 0 or inset*2 >= min(w,h): raise ValueError('inset 使采样区域无效')
crop = im.crop((x+inset,y+inset,x+w-inset,y+h-inset))
# 只统计完全不透明的像素,不将透明底错误统计为黑色。
pixels = [p for p in crop.get_flattened_data() if p[3] == 255] if hasattr(crop,'get_flattened_data') else [p for p in crop.getdata() if p[3] == 255]
if not pixels: raise ValueError(f'{name} 没有不透明的颜色样本')
channels = [distribution([p[c] for p in pixels]) for c in range(3)]
rgb = [round(c['median']) for c in channels]
region['color'] = {'median_hex':'#' + ''.join(f'{v:02X}' for v in rgb),'rgb_channels':channels,
'opaque_samples':len(pixels),'excluded_nonopaque':crop.width*crop.height-len(pixels),
'sample_box':[x+inset,y+inset,w-inset*2,h-inset*2],
'max_channel_p90_minus_p10':round(max(c['p90']-c['p10'] for c in channels),3),
'meaning':'局部像素统计,不证明原始色值、纯色或渐变模型'}
regions.append(region)
groups=[]
for name in sorted({r['group'] for r in regions if 'group' in r}):
members=sorted([r for r in regions if r.get('group')==name],key=lambda r:r['box'][0])
if len(members)<2: raise ValueError('重复网格至少需要两个标注')
gaps=[b['box'][0]-(a['box'][0]+a['box'][2]) for a,b in zip(members,members[1:])]
groups.append({'name':name,'members':[r['name'] for r in members],'x_gaps':gaps,
'median_width':median([r['box'][2] for r in members]),'median_height':median([r['box'][3] for r in members]),
'median_gap':median(gaps),'y_spread':max(r['box'][1] for r in members)-min(r['box'][1] for r in members),
'width_spread':max(r['box'][2] for r in members)-min(r['box'][2] for r in members),
'meaning':'横向重复结构的描述统计,不自动把不等宽设计改成等宽'})
scans=[]
for spec in data.get('vertical_scans',[]):
name=take_name(spec)
x,y,_,h=box_checked([spec['x'],spec['y'],1,spec['height']],im.size)
floor=integer(spec['min_channel'],'min_channel'); spread=integer(spec['max_channel_spread'],'max_channel_spread')
run_min=integer(spec.get('min_run',1),'min_run')
if not 0<=floor<=255 or not 0<=spread<=255 or run_min<1: raise ValueError('扫描阈值无效')
runs=[];start=None
for yy in range(y,y+h+1):
p=im.getpixel((x,yy)) if yy<y+h else None
match=p is not None and p[3]==255 and min(p[:3])>=floor and max(p[:3])-min(p[:3])<=spread
if match and start is None: start=yy
if not match and start is not None:
if yy-start>=run_min:runs.append({'y_start':start,'y_end_exclusive':yy,'height':yy-start})
start=None
scans.append({'name':name,'configuration':spec,'runs':runs,'meaning':'满足给定颜色阈值的连续像素,不自动认定为区块边界'})
if not regions and not scans: raise ValueError('至少提供一个区域或扫描')
result={'source':str(source),'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'source_size':list(im.size),
'css_mapping':{'assumed_viewport_width':css_width,'css_per_image_pixel':factor,'meaning':'实现约定,不证明参考图原始 DPR 或 CSS 视口'},
'regions':regions,'groups':groups,'vertical_scans':scans}
if 'expected' in data:
result['expected'] = data['expected']
result['expected_assets'] = data.get('expected_assets', {})
result['input_manifest_sha256'] = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
# 所有输入检查完成后才创建输出,且拒绝复用已有轮次。
out.mkdir(parents=True,exist_ok=False)
(out/'measurements.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
# PNG 编码原图用于自包含的 SVG 标注层;不缩放、去背或重建素材。
import io
buffer=io.BytesIO();im.save(buffer,format='PNG')
encoded=base64.b64encode(buffer.getvalue()).decode('ascii')
svg=[f'<svg xmlns="http://www.w3.org/2000/svg" width="{im.width}" height="{im.height}" viewBox="0 0 {im.width} {im.height}">',f'<image width="{im.width}" height="{im.height}" href="data:image/png;base64,{encoded}"/>']
for region in regions:
x,y,w,h=region['box'];label=html.escape(region['name']);color='#36edcc' if region['kind']=='box' else '#f6cb46'
svg.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="none" stroke="{color}" stroke-width="2"/><text x="{x+3}" y="{y+15}" fill="{color}" stroke="#000" stroke-width="3" paint-order="stroke" font-size="13" font-family="sans-serif">{label}</text>')
svg.append('</svg>')
(out/'annotations.svg').write_text('\n'.join(svg),encoding='utf-8')
return result
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest',type=Path,required=True)
parser.add_argument('--out-dir',type=Path,required=True)
args=parser.parse_args()
try:
result=measure(args.manifest,args.out_dir)
print(json.dumps({'status':'标注区域测量完成,设计关系仍需判断','regions':len(result['regions']),'groups':len(result['groups']),'output':str(args.out_dir)},ensure_ascii=False))
except (OSError,ValueError,KeyError,TypeError) as error:
parser.exit(2,f'测量失败:{error}\n')
if __name__=='__main__':main()