设计改版后,开发和产品需要知道“哪些屏变了、变在哪里”。Uizard 可以把屏幕导出成图片,但没有版本差异报告。 这篇做
export_audit.py:先检查导出文件(数量上限、格式、倍率是否一致、是否透明背景),再对两次导出做逐屏视觉对比,并用红框标出改动。
先看清成品(box 上真实运行,用我合成的模拟导出图)
- v1 → v2:Home 改动 4.65%,红框在按钮位置;Profile 改动 6.28%,位置在新增行(y≈800–912);Settings 是 0。
- 混用 2x 和 3x 会收到警告;100 个文件会报错“需要拆分项目”。
- 同一屏分别存成 q95 和 q60 的 JPG,修复后不再误报。pytest 7 项通过。
合规提醒:Uizard 没有公开 API,本文只用官方网页功能(Wireframe Scanner、Autodesigner、Handoff 导出),本地脚本只处理你自己的图片和文字,不抓取、不自动化操作 Uizard 网页,也不共享账号。Autodesigner 和组件代码导出需要 Pro 及以上套餐(以官方定价页为准,本文不列价格)。上传的草图、截图要是你有权使用的内容,不要上传别人受版权保护的界面去“复刻”商用。
运行说明:box 上没有 Uizard 账号,所以没有在 Uizard 里真正上传或生成过。本地脚本和测试都在 box 上真实运行过(Python 3.13、Pillow 13.0.0、numpy 2.5.4);测试用的草图照片和“导出图”是我自己用 Pillow 合成的,不是 Uizard 的真实素材。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
export_audit.py audit |
检查一个导出文件夹 |
export_audit.py diff |
两版逐屏对比,输出 diff_*.png |
make_mock_exports.py |
生成模拟导出图 |
test_export_audit.py |
7 项测试 |
需要准备哪些环境?
| 项目 | 版本 |
|---|---|
| Python | 3.9 以上 |
| 依赖 | pip install pillow numpy pytest |
| Uizard | 在 Handoff 模式中导出图片 |
官方导出规则
| 事实 | 来源 |
|---|---|
| 可以导出 JPG、PNG、SVG、PDF,倍率 1x~4x;PNG 可选透明背景 | Exporting projects |
| 组件可以导出 CSS 或 React 代码(需要 Pro 及以上),不能把整个项目导出成代码 | Exporting projects、React 代码博客 |
| 导出 100 屏及以上不受支持,需要拆分项目 | Troubleshooting exports |
文件结构
64-uizard-C-src/
├── export_audit.py
├── make_mock_exports.py
└── test_export_audit.py
第 1 步:在 Uizard 里导出(手动)
- 打开项目,进入 Handoff 模式。
- 选择 PNG,两次导出都用同一倍率(例如 2x),分别放进
exports/v1/和exports/v2/。 - 两次导出的屏幕名称要保持一致,脚本按文件名配对。
(本文没有实际导出;下面用模拟图代替。)
第 2 步:模拟导出图
"""生成两版“导出的屏幕图”(我自己画的模拟图,不是 Uizard 真实导出):v1、v2 各 3 屏,375 宽 × 2 倍 = 750 px。
v2 里 home 的按钮颜色改了、profile 多了一行,settings 不变。"""
import os
from PIL import Image, ImageDraw
def screen(name, btn=(60, 110, 230), extra_row=False, scale=2, alpha=False):
W, H = 375 * scale, 812 * scale
img = Image.new("RGBA" if alpha else "RGB", (W, H), (0, 0, 0, 0) if alpha else (250, 250, 252)); d = ImageDraw.Draw(img)
d.rectangle((0, 0, W, 88 * scale), fill=(30, 30, 40))
d.text((20 * scale, 40 * scale), name, fill=(255, 255, 255))
for i in range(4 + extra_row):
y = (120 + i * 70) * scale
d.rounded_rectangle((20 * scale, y, W - 20 * scale, y + 54 * scale), 8 * scale, fill=(230, 232, 238))
d.rounded_rectangle((40 * scale, 680 * scale, W - 40 * scale, 730 * scale), 25 * scale, fill=btn)
return img
def build(root="mock_exports"):
for v in ("v1", "v2"): os.makedirs(f"{root}/{v}", exist_ok=True)
screen("Home").save(f"{root}/v1/Home.png"); screen("Profile").save(f"{root}/v1/Profile.png"); screen("Settings").save(f"{root}/v1/Settings.png")
screen("Home", btn=(20, 160, 90)).save(f"{root}/v2/Home.png"); screen("Profile", extra_row=True).save(f"{root}/v2/Profile.png")
screen("Settings").save(f"{root}/v2/Settings.png")
if __name__ == "__main__":
build(); print("ok")
第 3 步:检查和对比工具
"""检查 Uizard 导出的屏幕图片,并比较两个版本。
依据官方帮助文档 Exporting projects / Troubleshooting exports:可导出 JPG、PNG、SVG、PDF,倍率 1x–4x,PNG 可选透明背景;
一次导出 100 屏及以上不受支持(需要拆分项目)。本工具只读本地文件,不调用 Uizard。"""
import argparse, json, os, sys
from PIL import Image, ImageChops, ImageDraw
import numpy as np
RASTER = {".png", ".jpg", ".jpeg"}
ALLOWED = RASTER | {".svg", ".pdf"}
def audit(folder, base_width=None):
files = sorted(f for f in os.listdir(folder) if not f.startswith("."))
rep = {"folder": folder, "count": len(files), "errors": [], "warnings": [], "screens": []}
if len(files) >= 100:
rep["errors"].append("100 屏及以上的导出不受支持:请把项目拆分后再导出")
widths = set()
for f in files:
ext = os.path.splitext(f)[1].lower(); s = {"file": f, "ext": ext}
if ext not in ALLOWED:
rep["errors"].append(f"{f}: 不是 Uizard 的导出格式"); continue
if ext in RASTER:
with Image.open(os.path.join(folder, f)) as im:
s["size"] = im.size; widths.add(im.width)
if im.mode in ("RGBA", "LA") or "transparency" in im.info:
s["transparent"] = bool(np.asarray(im.convert("RGBA"))[..., 3].min() < 255)
if base_width:
sc = im.width / base_width; s["scale"] = round(sc, 3)
if sc not in (1, 2, 3, 4):
rep["warnings"].append(f"{f}: 宽 {im.width} 不是 {base_width} 的 1–4 整数倍")
rep["screens"].append(s)
if len(widths) > 1:
rep["warnings"].append(f"图片宽度不一致 {sorted(widths)}:可能混用了不同倍率或设备")
return rep
def diff_pair(a, b, out_png, tol=16, cell=16, cell_min=0.5):
"""逐像素比较。JPG 的压缩噪点是零散分布的,所以只把“16px 格子里至少 50% 像素变化”的格子算作真实改动。"""
A = Image.open(a).convert("RGB"); B = Image.open(b).convert("RGB")
if A.size != B.size:
return {"changed": None, "note": f"尺寸不同 {A.size} → {B.size},无法逐像素比较"}
d = np.asarray(ImageChops.difference(A, B)).max(axis=2) > tol
H, W = d.shape; gh, gw = H // cell, W // cell
g = d[:gh * cell, :gw * cell].reshape(gh, cell, gw, cell).mean(axis=(1, 3)) >= cell_min
pct = float(g.mean() * 100)
if not g.any():
return {"changed": 0.0, "raw_pixels_pct": round(float(d.mean() * 100), 3)}
ys, xs = np.nonzero(g)
box = (int(xs.min() * cell), int(ys.min() * cell), int((xs.max() + 1) * cell), int((ys.max() + 1) * cell))
vis = B.copy(); dr = ImageDraw.Draw(vis)
for y, x in zip(ys, xs):
dr.rectangle((x * cell, y * cell, (x + 1) * cell - 1, (y + 1) * cell - 1), outline=(255, 0, 0), width=2)
vis.save(out_png)
return {"changed": round(pct, 2), "bbox": box, "image": out_png}
def compare(v1, v2, outdir):
os.makedirs(outdir, exist_ok=True)
s1 = {f for f in os.listdir(v1) if os.path.splitext(f)[1].lower() in RASTER}
s2 = {f for f in os.listdir(v2) if os.path.splitext(f)[1].lower() in RASTER}
res = {"added": sorted(s2 - s1), "removed": sorted(s1 - s2), "screens": {}}
for f in sorted(s1 & s2):
res["screens"][f] = diff_pair(os.path.join(v1, f), os.path.join(v2, f), os.path.join(outdir, "diff_" + os.path.splitext(f)[0] + ".png"))
return res
if __name__ == "__main__":
ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True)
a1 = sub.add_parser("audit"); a1.add_argument("folder"); a1.add_argument("--base-width", type=int)
a2 = sub.add_parser("diff"); a2.add_argument("v1"); a2.add_argument("v2"); a2.add_argument("--out", default="diff_out")
a = ap.parse_args()
r = audit(a.folder, a.base_width) if a.cmd == "audit" else compare(a.v1, a.v2, a.out)
print(json.dumps(r, ensure_ascii=False, indent=2))
sys.exit(1 if r.get("errors") else 0)
第 4 步:测试
import os, pytest
import export_audit as E, make_mock_exports as M
@pytest.fixture
def ex(tmp_path):
M.build(str(tmp_path / "m")); return tmp_path / "m"
def test_audit_ok(ex):
r = E.audit(str(ex / "v1"), 375)
assert r["errors"] == [] and r["warnings"] == [] and {s["scale"] for s in r["screens"]} == {2.0}
def test_mixed_scale_and_transparency(tmp_path):
M.screen("A", scale=2).save(tmp_path / "A.png"); M.screen("B", scale=3).save(tmp_path / "B.png")
M.screen("C", scale=2, alpha=True).save(tmp_path / "C.png")
r = E.audit(str(tmp_path), 375)
assert any("宽度不一致" in w for w in r["warnings"]) and r["screens"][2]["transparent"] is True
def test_100_screens(tmp_path):
for i in range(100): (tmp_path / f"s{i:03}.svg").write_text("<svg/>")
assert E.audit(str(tmp_path))["errors"]
os.remove(tmp_path / "s000.svg"); assert not E.audit(str(tmp_path))["errors"]
def test_bad_format(tmp_path):
(tmp_path / "x.gif").write_bytes(b"GIF89a"); assert E.audit(str(tmp_path))["errors"]
def test_diff(ex, tmp_path):
r = E.compare(str(ex / "v1"), str(ex / "v2"), str(tmp_path / "d"))
s = r["screens"]
assert s["Settings.png"]["changed"] == 0 and s["Home.png"]["changed"] > 1
assert s["Profile.png"]["bbox"][1] >= 800 # 新增的那一行在 y≈840
def test_jpeg_noise_ignored(tmp_path):
# 调试时发现:同一屏分别存为 q95 和 q60 的 JPG,按像素比较有 95% 不同,旧的 5% 格子阈值仍然报告 0.45% 改动
M.screen("Home").save(tmp_path / "a.jpg", quality=95); M.screen("Home").save(tmp_path / "b.jpg", quality=60)
assert E.diff_pair(str(tmp_path / "a.jpg"), str(tmp_path / "b.jpg"), str(tmp_path / "d.png"))["changed"] == 0
def test_size_mismatch(tmp_path):
M.screen("A", scale=1).save(tmp_path / "a.png"); M.screen("A", scale=2).save(tmp_path / "b.png")
assert E.diff_pair(str(tmp_path / "a.png"), str(tmp_path / "b.png"), "x")["changed"] is None
第 5 步:运行
$ python make_mock_exports.py
ok
$ python export_audit.py audit mock_exports/v2 --base-width 375
{"count": 3, "errors": [], "warnings": [], "screens": [{"file": "Home.png", "size": [750, 1624], "scale": 2.0}, ...]}
$ python export_audit.py diff mock_exports/v1 mock_exports/v2 --out diff_out
{"added": [], "removed": [],
"screens": {"Home.png": {"changed": 4.65, "bbox": [80, 1360, 672, 1456], "image": "diff_out/diff_Home.png"},
"Profile.png": {"changed": 6.28, "bbox": [32, 800, 704, 912], ...},
"Settings.png": {"changed": 0.0, "raw_pixels_pct": 0.0}}}
$ python -m pytest -q
7 passed
怎么检查结果?
- 打开
diff_out/diff_Home.png:红色小格应该只出现在按钮上。 - 没有改动的屏
changed为 0。 added、removed中列出新增或删除的屏。
调试示例
JPG 压缩噪点被当成改动。 我把同一屏分别存成质量 95 和 60 的 JPG 做对比:
- 按像素比较(容差 0):95.16% 不同;
- 容差 16:仍有 0.09%,而且包围框几乎覆盖整屏,完全没有参考价值。
第一次修复:把图切成 16px 的格子,格子里至少 5% 像素变化才算改动。结果仍然报 0.45%,因为 JPG 的振铃噪点集中在形状边缘,边缘格子很容易超过 5%。
最终改为 50%:噪点降为 0,真实改动(按钮整块换色、新增一行)仍然能检出。代价是只改了 1px 细线这类改动可能漏掉,所以建议用 PNG 导出做对比。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
尺寸不同,无法逐像素比较 |
两次导出倍率不同或屏幕尺寸变了 | 用同一倍率重新导出 |
| 宽度不一致警告 | 混用了倍率或设备 | 统一倍率 |
| 100 屏报错 | 超出官方导出限制 | 拆分项目后分批导出 |
| 细小文字改动没检出 | 50% 格子阈值的代价 | 用 PNG,必要时调小 cell_min |
| 屏幕被当成新增或删除 | 改了屏幕名称 | 保持名称一致 |
模板规则
- 对比只用 PNG,并且使用同一倍率。
- 屏幕名称就是对比的键,不要随意改名。
- 报告只作为提示,最终以人工查看红框为准。
项目资源
| 用途 | 地址 |
|---|---|
| Exporting projects | https://support.uizard.io/en/articles/6380330-exporting-projects |
| Troubleshooting exports | https://support.uizard.io/en/articles/7950885-troubleshooting-exports |
| React 组件代码导出(官方博客) | https://uizard.io/blog/generate-react-code-for-any-component/ |
每次改版都导出一次、对比一次,评审时就能直接看到哪些地方变了。