修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。
二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。
三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
This commit is contained in:
1 parent
e03465c398
commit
237a09a5b0
161 files changed
+19429
-2
No files matched your search
@@ -0,0 +1,156 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user