手机拍的手绘线框图,常见问题是阴影、纸面发黄、拍进了桌面杂物,或者忘了画屏幕外框,导致 Wireframe Scanner 识别不准。 这篇做一个本地预处理工具
sketch_prep.py:上传前检查格式、校正光照、二值化、裁剪到笔迹范围,并检查有没有屏幕外框。
先看清成品(box 上真实运行)
- 合成的带阴影草图照片:光照差异 0.475,被提醒并校正;输出 840×1560 的黑白 PNG,四边外框覆盖率 0.96~0.98,判定“有外框”。
- 去掉外框的版本:退出码 2,提示“请在线框外画一个设备屏幕矩形”。
- pytest 4 项通过。
合规提醒: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 的真实素材。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
sketch_prep.py |
预处理并输出 JSON 报告;没有外框时退出码为 2 |
make_sample.py |
合成测试照片(带阴影、噪点) |
test_sketch_prep.py |
4 项测试 |
需要准备哪些环境?
| 项目 | 版本 |
|---|---|
| Python | 3.9 以上(box 上是 3.13) |
| 依赖 | pip install pillow numpy pytest |
| Uizard | 网页版,Wireframe Scanner 在新建项目或 Magic 菜单里 |
官方规则(工具检查依据)
| 规则 | 来源 |
|---|---|
| 上传 PNG、JPEG 或 HEIC | Using Wireframe Scanner |
| 在均匀的表面上画;用屏幕外框把线框圈起来 | 官方 sketching guide |
| 文字画波浪线,图片画打叉方框,按钮和输入框画清楚的矩形 | 官方 sketching guide |
| 识别不准大多是没按绘制规则画;仍然识别错的组件,手动编辑 | Troubleshooting wireframes |
文件结构
64-uizard-A-src/
├── sketch_prep.py
├── make_sample.py
└── test_sketch_prep.py
第 1 步:生成测试照片
"""生成测试用的“手机拍摄草图”照片(我自己合成的,不是 Uizard 的素材):
米色纸、从左上到右下的阴影、噪点、轻微模糊;纸上画手机屏幕外框、打叉的图片框、表示文字的波浪线、输入框和按钮。"""
from PIL import Image, ImageDraw, ImageFilter
import numpy as np
def make(path, shadow=0.45, framed=True, seed=1, size=(1600, 2000)):
W, H = size
img = Image.new("RGB", size, (236, 228, 210)); d = ImageDraw.Draw(img)
ink = (40, 40, 48)
x0, y0, x1, y1 = 420, 260, 1180, 1740
if framed:
d.rounded_rectangle((x0, y0, x1, y1), 40, outline=ink, width=8) # 屏幕外框
d.rectangle((x0 + 60, y0 + 80, x1 - 60, y0 + 480), outline=ink, width=6) # 打叉 = 图片
d.line((x0 + 60, y0 + 80, x1 - 60, y0 + 480), fill=ink, width=6)
d.line((x0 + 60, y0 + 480, x1 - 60, y0 + 80), fill=ink, width=6)
for row in range(3): # 波浪线 = 文字
y = y0 + 560 + row * 70
d.line([(x0 + 60 + i * 20, y + (12 if i % 2 else -12)) for i in range(30)], fill=ink, width=5)
d.rectangle((x0 + 60, y0 + 820, x1 - 60, y0 + 920), outline=ink, width=6) # 输入框
d.rounded_rectangle((x0 + 180, y0 + 1100, x1 - 180, y0 + 1220), 30, outline=ink, width=6) # 按钮
a = np.asarray(img).astype(np.float32)
yy, xx = np.mgrid[0:H, 0:W]
grad = 1 - shadow * (1 - (xx / W + yy / H) / 2)
a = a * grad[..., None] + np.random.default_rng(seed).normal(0, 6, a.shape)
Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(1.2)).save(path, quality=88)
if __name__ == "__main__":
import os; os.makedirs("samples", exist_ok=True)
make("samples/sketch_photo.jpg")
make("samples/sketch_noframe.jpg", framed=False, seed=2)
print("ok")
第 2 步:预处理工具
"""把手机拍的手绘线框图整理成更适合 Uizard Wireframe Scanner 上传的图片。
官方要求(帮助文档 Using Wireframe Scanner / sketching guide):上传 PNG、JPEG 或 HEIC;表面要均匀;用屏幕外框把线框圈起来;
文字用波浪线,图片用打叉的方框,按钮和输入框画清楚的矩形。
本工具只在本地处理图片,不调用 Uizard。"""
import argparse, json, sys
from PIL import Image, ImageFilter, ImageOps
import numpy as np
def sniff(path):
head = open(path, "rb").read(16)
if head.startswith(b"\x89PNG"): return "png"
if head[:3] == b"\xff\xd8\xff": return "jpeg"
if head[4:8] == b"ftyp" and head[8:12] in (b"heic", b"heix", b"mif1", b"heif"): return "heic"
return "unknown"
def otsu(g):
hist = np.bincount(g.ravel(), minlength=256).astype(float); p = hist / hist.sum()
w = np.cumsum(p); m = np.cumsum(p * np.arange(256)); mt = m[-1]
var = (mt * w - m) ** 2 / np.maximum(w * (1 - w), 1e-12)
return int(np.argmax(var))
def lighting_spread(gray):
"""用大半径模糊估计背景亮度,返回 (最亮 - 最暗) / 平均。数值大说明光照不均或有阴影。"""
bg = np.asarray(gray.filter(ImageFilter.GaussianBlur(60))).astype(float)
lo, hi = np.percentile(bg, [2, 98])
return (hi - lo) / max(bg.mean(), 1), bg
def has_frame(ink, min_cover=0.8):
"""裁剪后的墨迹图:靠外侧的行和列里是否都有一条贯穿大部分宽度或高度的线(即屏幕外框)。"""
H, W = ink.shape; band = max(3, int(0.03 * min(H, W)))
def cover(lines): return max(l.mean() for l in lines)
top = cover(ink[:band]); bot = cover(ink[-band:])
left = cover(ink[:, :band].T); right = cover(ink[:, -band:].T)
return min(top, bot, left, right) >= min_cover, dict(top=top, bottom=bot, left=left, right=right)
def prep(src, dst, max_side=2000, margin=40):
report = {"format": sniff(src), "warnings": []}
if report["format"] == "unknown":
raise SystemExit("不是 PNG、JPEG 或 HEIC,Wireframe Scanner 不支持")
if report["format"] == "heic":
raise SystemExit("HEIC 可以直接上传到 Uizard;本工具不解码 HEIC(需要 pillow-heif),请直接上传或先转成 JPEG")
img = ImageOps.exif_transpose(Image.open(src)); gray = img.convert("L")
spread, bg = lighting_spread(gray)
report["lighting_spread"] = round(spread, 3)
if spread > 0.25:
report["warnings"].append("光照不均:已做亮度校正;下次请在均匀光线、单一颜色的平面上拍")
flat = np.clip(np.asarray(gray).astype(float) / np.maximum(bg, 1) * 255, 0, 255).astype(np.uint8)
t = otsu(flat); ink = flat < t
report["threshold"] = int(t); report["ink_ratio"] = round(float(ink.mean()), 4)
ys, xs = np.nonzero(ink)
if len(xs) == 0:
raise SystemExit("没有找到笔迹")
# 去掉零星噪点:用 0.1% / 99.9% 分位数确定裁剪范围
x0, x1 = np.percentile(xs, [0.1, 99.9]).astype(int); y0, y1 = np.percentile(ys, [0.1, 99.9]).astype(int)
framed, cov = has_frame(ink[y0:y1 + 1, x0:x1 + 1])
report["frame"] = {"found": bool(framed), **{k: round(float(v), 2) for k, v in cov.items()}}
if not framed:
report["warnings"].append("没有检测到包围线框的屏幕外框:请在线框外画一个设备屏幕矩形")
out = Image.fromarray(np.where(ink, 0, 255).astype(np.uint8))
out = out.crop((max(x0 - margin, 0), max(y0 - margin, 0), min(x1 + margin, out.width), min(y1 + margin, out.height)))
if max(out.size) > max_side:
out.thumbnail((max_side, max_side), Image.LANCZOS)
out.save(dst, "PNG", optimize=True)
report["output"] = {"path": dst, "size": out.size}
return report
if __name__ == "__main__":
ap = argparse.ArgumentParser(); ap.add_argument("src"); ap.add_argument("dst"); ap.add_argument("--max-side", type=int, default=2000)
a = ap.parse_args()
r = prep(a.src, a.dst, a.max_side)
print(json.dumps(r, ensure_ascii=False, indent=2))
sys.exit(0 if r["frame"]["found"] else 2)
第 3 步:测试
import json, pytest
from PIL import Image
import sketch_prep as S, make_sample as M
@pytest.fixture(scope="module")
def samples(tmp_path_factory):
d = tmp_path_factory.mktemp("s")
M.make(str(d / "f.jpg")); M.make(str(d / "n.jpg"), framed=False, seed=2)
return d
def test_framed(samples, tmp_path):
r = S.prep(str(samples / "f.jpg"), str(tmp_path / "o.png"))
assert r["frame"]["found"] and r["format"] == "jpeg"
assert 0.01 < r["ink_ratio"] < 0.06 # 不做亮度校正时左上角整片会被当成墨迹(实测 0.32)
json.dumps(r) # 必须能序列化(曾因 numpy.bool_ 报错)
def test_noframe_warns(samples, tmp_path):
r = S.prep(str(samples / "n.jpg"), str(tmp_path / "o.png"))
assert not r["frame"]["found"] and any("外框" in w for w in r["warnings"])
def test_formats(tmp_path):
p = tmp_path / "x.gif"; Image.new("RGB", (10, 10)).save(p, "GIF")
with pytest.raises(SystemExit): S.prep(str(p), str(tmp_path / "o.png"))
h = tmp_path / "x.heic"; h.write_bytes(b"\x00\x00\x00\x18ftypheic" + b"\x00" * 16)
assert S.sniff(str(h)) == "heic"
with pytest.raises(SystemExit, match="HEIC"): S.prep(str(h), str(tmp_path / "o.png"))
def test_max_side(samples, tmp_path):
r = S.prep(str(samples / "f.jpg"), str(tmp_path / "o.png"), max_side=800)
assert max(r["output"]["size"]) <= 800
第 4 步:运行
$ python make_sample.py
ok
$ python sketch_prep.py samples/sketch_photo.jpg out/sketch_photo.png
{"format": "jpeg", "warnings": ["光照不均:已做亮度校正;下次请在均匀光线、单一颜色的平面上拍"],
"lighting_spread": 0.475, "threshold": 166, "ink_ratio": 0.0264,
"frame": {"found": true, "top": 0.96, "bottom": 0.96, "left": 0.98, "right": 0.98},
"output": {"path": "out/sketch_photo.png", "size": [840, 1560]}}
$ python sketch_prep.py samples/sketch_noframe.jpg out/sketch_noframe.png; echo $?
... "warnings": [..., "没有检测到包围线框的屏幕外框:请在线框外画一个设备屏幕矩形"],
"frame": {"found": false, "top": 1.0, "bottom": 0.59, "left": 0.46, "right": 0.44} ...
2
$ python -m pytest -q
4 passed
然后在 Uizard 里新建项目,选择 Wireframe Scanner,上传 out/sketch_photo.png。(这一步本文没有实际操作。)
怎么检查结果?
- 打开输出 PNG:应该只有黑色笔迹和白色背景,四角没有黑块。
ink_ratio一般只有百分之几;如果达到几十,说明阴影被当成了笔迹。frame.found为 true。
调试示例
1. 不做光照校正,阴影会被当成笔迹。 第一版直接对灰度图做 Otsu 二值化:阈值 165,ink_ratio 是 0.3244,左上角阴影整片变黑。改成先用 60px 高斯模糊估计背景亮度、再用原图除以背景之后,ink_ratio 降到 0.0264,输出只剩线条。
2. JSON 输出报错。 第一次运行时 json.dumps 抛出 TypeError: Object of type bool is not JSON serializable:has_frame 返回的是 numpy.bool_,不是 Python 的 bool。改成 bool(framed)、int(t) 后正常,测试里也加了 json.dumps(r) 一行。
3. 外框判定的局限。 无外框版本中,图片框的上边是一条横贯的直线,所以 top 覆盖率也是 1.0;只有四条边都 ≥ 0.8 才算有外框,靠的是左右两侧。如果草图本身就有横跨整屏的组件,可能误判,请以肉眼检查为准。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 提示“不是 PNG、JPEG 或 HEIC” | 格式不支持(例如 GIF、WebP) | 另存为 PNG 或 JPEG |
| HEIC 文件被拒绝 | 本工具不解码 HEIC | Uizard 本身支持 HEIC,可以直接上传 |
| 输出里有大片黑块 | 拍摄时阴影太重或背景是深色桌面 | 换均匀光线和单色平面重拍 |
| 线条断断续续 | 笔太细或太浅 | 用深色粗笔 |
| 上传后组件识别错 | 绘制规则没遵守或 AI 识别误差 | 按 sketching guide 重画,或在 Uizard 里手动改 |
模板规则
- 先按 sketching guide 画,再用工具检查;工具不能替代正确的画法。
- 外框、波浪线、打叉方框、清楚的矩形是四个关键符号。
- 上传前看一眼输出 PNG。
项目资源
| 用途 | 地址 |
|---|---|
| Using Wireframe Scanner | https://support.uizard.io/en/articles/6435370-using-wireframe-scanner |
| Troubleshooting wireframes | https://support.uizard.io/en/articles/6435363-troubleshooting-wireframes |
| Sketching guide | https://app.uizard.io/sketching-guide |
把照片整理干净、外框画好,Wireframe Scanner 才有更好的识别基础。