Files
Shixun/ppt/gen_assets_v2.py
2026-07-31 12:27:41 +08:00

798 lines
34 KiB
Python

# -*- coding: utf-8 -*-
"""Colorful diagram assets for defense PPT v2."""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
OUT = Path(__file__).resolve().parent / "assets_v2"
OUT.mkdir(parents=True, exist_ok=True)
# Colorful palette
C = {
"navy": (15, 32, 90),
"blue": (37, 99, 235),
"cyan": (6, 182, 212),
"teal": (20, 184, 166),
"green": (34, 197, 94),
"lime": (132, 204, 22),
"yellow": (234, 179, 8),
"orange": (249, 115, 22),
"coral": (251, 113, 133),
"pink": (236, 72, 153),
"purple": (168, 85, 247),
"violet": (139, 92, 246),
"indigo": (99, 102, 241),
"slate": (51, 65, 85),
"dark": (15, 23, 42),
"muted": (100, 116, 139),
"light": (248, 250, 252),
"white": (255, 255, 255),
"soft": (241, 245, 249),
}
def font(size, bold=False):
cands = [
r"C:\Windows\Fonts\msyhbd.ttc" if bold else r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\simhei.ttf",
r"C:\Windows\Fonts\arial.ttf",
]
for p in cands:
try:
return ImageFont.truetype(p, size)
except OSError:
continue
return ImageFont.load_default()
def rr(d, xy, r, fill, outline=None, w=2):
d.rounded_rectangle(xy, radius=r, fill=fill, outline=outline, width=w)
def tc(d, xy, text, f, fill):
b = d.textbbox((0, 0), text, font=f)
d.text((xy[0] - (b[2] - b[0]) // 2, xy[1] - (b[3] - b[1]) // 2), text, font=f, fill=fill)
def tl(d, xy, text, f, fill):
d.text(xy, text, font=f, fill=fill)
def gradient(w, h, c1, c2, horizontal=False):
img = Image.new("RGB", (w, h), c1)
px = img.load()
for y in range(h):
for x in range(w):
t = (x / max(w - 1, 1)) if horizontal else (y / max(h - 1, 1))
px[x, y] = tuple(int(c1[i] * (1 - t) + c2[i] * t) for i in range(3))
return img
def cover():
w, h = 1920, 1080
img = gradient(w, h, (15, 23, 72), (8, 100, 140))
ov = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(ov)
colors = [C["cyan"], C["purple"], C["coral"], C["teal"], C["orange"], C["indigo"]]
for i, (cx, cy, r) in enumerate([(200, 180, 200), (1700, 200, 240), (300, 900, 180), (1600, 880, 220), (960, 540, 380)]):
col = colors[i % len(colors)]
d.ellipse((cx - r, cy - r, cx + r, cy + r), outline=(*col, 70), width=4)
pts = [(280, 300), (520, 220), (800, 400), (1100, 280), (1400, 450), (1650, 320), (1200, 700), (700, 750)]
for i in range(len(pts) - 1):
d.line([pts[i], pts[i + 1]], fill=(*C["cyan"], 90), width=3)
for i, p in enumerate(pts):
col = colors[i % len(colors)]
d.ellipse((p[0] - 10, p[1] - 10, p[0] + 10, p[1] + 10), fill=(*col, 200))
# glass panel
d.rounded_rectangle((160, 200, 1760, 880), radius=36, fill=(10, 25, 60, 150), outline=(*C["cyan"], 120), width=3)
# colorful bars
for i, col in enumerate([C["coral"], C["orange"], C["yellow"], C["teal"], C["blue"], C["purple"]]):
d.rounded_rectangle((220 + i * 250, 780, 440 + i * 250, 820), radius=10, fill=(*col, 220))
img = Image.alpha_composite(img.convert("RGBA"), ov).convert("RGB")
img.save(OUT / "cover.png", quality=95)
def dual_chain():
w, h = 1500, 700
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (40, 20), "双核心业务链路", font(28, True), C["dark"])
chains = [
(70, "链路 A · 医学影像 AI 读片", C["blue"], C["cyan"],
[("影像检查", "X光/CT/MRI/超声"), ("YOLO 检测", "框选+置信度"), ("结构化报告", "所见/印象/建议")]),
(390, "链路 B · 电子病历辅助决策", C["purple"], C["pink"],
[("病历录入", "主诉→诊断→用药"), ("RAG + LLM", "知识检索生成"), ("决策建议", "治疗/护理/随访")]),
]
for y, title, c1, c2, nodes in chains:
rr(d, (30, y, w - 30, y + 280), 20, C["white"], c1, 3)
d.rectangle((30, y, 48, y + 280), fill=c1)
tl(d, (60, y + 18), title, font(24, True), c1)
bw, bh = 360, 120
gap = 40
for i, (a, b) in enumerate(nodes):
x = 70 + i * (bw + gap)
col = c1 if i % 2 == 0 else c2
rr(d, (x, y + 90, x + bw, y + 90 + bh), 16, col)
tc(d, (x + bw // 2, y + 125), a, font(22, True), C["white"])
tc(d, (x + bw // 2, y + 165), b, font(16), (240, 250, 255))
if i < 2:
d.polygon([(x + bw + 8, y + 150), (x + bw + 32, y + 140), (x + bw + 32, y + 160)], fill=c1)
img.save(OUT / "dual_chain.png", quality=95)
def goals_radar():
"""Five goal cards as colorful strip."""
w, h = 1500, 320
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
goals = [
("业务闭环", "登录·患者·影像\n病历·预约·用户", C["blue"]),
("AI 可演示", "YOLO 实检\nLLM/模板降级", C["purple"]),
("前后端分离", "Vue3 + Boot\n+ FastAPI", C["teal"]),
("可离线实训", "默认 H2\nAI 可降级", C["orange"]),
("安全可讲", "JWT·角色\nBCrypt·双端", C["coral"]),
]
for i, (t, desc, col) in enumerate(goals):
x = 25 + i * 295
rr(d, (x, 30, x + 280, 290), 18, C["white"], col, 3)
rr(d, (x, 30, x + 280, 100), 18, col)
d.rectangle((x, 80, x + 280, 100), fill=col)
tc(d, (x + 140, 65), t, font(20, True), C["white"])
for j, ln in enumerate(desc.split("\n")):
tc(d, (x + 140, 150 + j * 36), ln, font(16), C["dark"])
img.save(OUT / "goals.png", quality=95)
def scenes():
w, h = 1500, 380
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
cards = [
("影像科", "上传胸片→AI诊断\n标注框·置信度·报告", C["cyan"]),
("临床医生", "病历关键词→建议\n治疗·护理·随访·RAG", C["blue"]),
("管理员", "LLM配置·知识库\nYOLO权重·用户", C["purple"]),
("挂号窗口", "预约状态流转\n预约→确认→完成", C["orange"]),
]
for i, (t, desc, col) in enumerate(cards):
x = 20 + i * 370
rr(d, (x, 25, x + 350, 350), 20, C["white"], col, 3)
rr(d, (x, 25, x + 350, 110), 20, col)
d.rectangle((x, 90, x + 350, 110), fill=col)
tc(d, (x + 175, 70), t, font(24, True), C["white"])
d.ellipse((x + 145, 135, x + 205, 195), fill=col)
tc(d, (x + 175, 165), str(i + 1), font(22, True), C["white"])
for j, ln in enumerate(desc.split("\n")):
tc(d, (x + 175, 230 + j * 32), ln, font(15), C["dark"])
img.save(OUT / "scenes.png", quality=95)
def pain_transform():
w, h = 1400, 520
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "从教学原型到三端 AI 混合架构", font(24, True), C["dark"])
rr(d, (40, 70, 520, 480), 18, C["white"], C["coral"], 3)
rr(d, (40, 70, 520, 130), 18, C["coral"])
d.rectangle((40, 110, 520, 130), fill=C["coral"])
tc(d, (280, 100), "早期痛点", font(22, True), C["white"])
for i, t in enumerate(["业务割裂 · 缺 360° 档案", "AI 假数据 · 无真实检测", "Thymeleaf 前后端耦合", "Session 权限薄弱", "依赖失败整链中断"]):
tl(d, (70, 160 + i * 55), "● " + t, font(18), C["dark"])
# arrow
d.polygon([(560, 250), (680, 220), (680, 240), (780, 240), (780, 260), (680, 260), (680, 280)], fill=C["teal"])
rr(d, (820, 70, 1360, 480), 18, C["white"], C["teal"], 3)
rr(d, (820, 70, 1360, 130), 18, C["teal"])
d.rectangle((820, 110, 1360, 130), fill=C["teal"])
tc(d, (1090, 100), "当前实现", font(22, True), C["white"])
for i, t in enumerate(["患者 360° 统一视图", "YOLO 实检 + LLM/RAG", "Vue3 SPA + REST", "JWT + 角色双端守卫", "AI 宕机可规则降级"]):
tl(d, (850, 160 + i * 55), "● " + t, font(18), C["dark"])
img.save(OUT / "pain.png", quality=95)
def priority():
w, h = 1500, 780
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "功能需求优先级看板", font(26, True), C["dark"])
cols = [
("P0 必须具备", C["coral"], [
"F-01 登录/改密 JWT+BCrypt",
"F-02 患者 CRUD + 360°",
"F-03 影像 CRUD + 上传",
"F-04 AI 诊断状态机",
"F-05 病历 + 辅助决策",
"F-06 角色权限双端",
]),
("P1 重要增强", C["orange"], [
"F-07 预约挂号全流程",
"F-08 仪表盘可视化",
"F-09 AI 对话助手",
"F-10 知识库 RAG",
"F-11 AI/LLM 配置",
"F-12 YOLO 权重管理",
]),
("P2 体验健壮", C["teal"], [
"F-13 AI 不可用降级",
"F-14 弹窗 append-to-body",
"F-15 报告分段可读",
"F-16 路由过渡体验",
]),
]
for i, (title, col, items) in enumerate(cols):
x = 30 + i * 490
rr(d, (x, 70, x + 460, h - 30), 18, C["white"], col, 3)
rr(d, (x, 70, x + 460, 140), 18, col)
d.rectangle((x, 120, x + 460, 140), fill=col)
tc(d, (x + 230, 105), title, font(22, True), C["white"])
yy = 170
for it in items:
rr(d, (x + 20, yy, x + 440, yy + 70), 12, C["soft"], col, 1)
tl(d, (x + 40, yy + 22), it, font(15), C["dark"])
yy += 85
img.save(OUT / "priority.png", quality=95)
def tech_stack():
w, h = 1500, 620
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "三层技术栈(版本对齐文档)", font(26, True), C["dark"])
layers = [
("前端 SPA :5173", C["blue"], "Vue 3.5.10 · Vite 5.4.8 · Element Plus 2.8.4 · Pinia · Router · Axios · ECharts · marked"),
("业务后端 :8080", C["purple"], "Spring Boot 3.3.4 · Java 17 · Security + jjwt 0.12.6 · JPA · H2/MySQL · Lombok · Maven"),
("AI 微服务 :8001", C["teal"], "FastAPI ≥0.110 · Ultralytics YOLO · OpenCV · LangChain ≥0.2 · DeepSeek 等 OpenAI 兼容"),
]
for i, (t, col, desc) in enumerate(layers):
y = 70 + i * 170
rr(d, (40, y, w - 40, y + 150), 18, C["white"], col, 4)
rr(d, (40, y, 280, y + 150), 18, col)
d.rectangle((220, y, 280, y + 150), fill=col)
tc(d, (160, y + 75), t, font(18, True), C["white"])
tl(d, (310, y + 55), desc, font(16), C["dark"])
img.save(OUT / "tech.png", quality=95)
def architecture():
w, h = 1600, 900
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (40, 20), "系统逻辑架构", font(28, True), C["dark"])
def box(x, y, bw, bh, title, lines, col):
rr(d, (x, y, x + bw, y + bh), 16, C["white"], col, 3)
rr(d, (x, y, x + bw, y + 48), 16, col)
d.rectangle((x, y + 30, x + bw, y + 48), fill=col)
tc(d, (x + bw // 2, y + 24), title, font(18, True), C["white"])
yy = y + 65
for ln in lines:
tc(d, (x + bw // 2, yy), ln, font(14), C["dark"])
yy += 24
box(560, 70, 480, 110, "浏览器 Vue SPA", ["localhost:5173 · Element Plus · ECharts"], C["blue"])
d.line([(800, 180), (800, 230)], fill=C["slate"], width=3)
d.polygon([(800, 240), (790, 225), (810, 225)], fill=C["slate"])
tl(d, (820, 200), "/api Vite 代理", font(14), C["muted"])
box(480, 250, 640, 130, "Spring Boot 业务端", ["8080 · JWT / JPA / 文件 · 鉴权落库 · 降级"], C["purple"])
d.line([(640, 380), (640, 450)], fill=C["blue"], width=3)
d.line([(1000, 380), (1000, 450)], fill=C["teal"], width=3)
d.line([(640, 450), (1000, 450)], fill=C["muted"], width=2)
d.polygon([(640, 465), (630, 450), (650, 450)], fill=C["blue"])
d.polygon([(1000, 465), (990, 450), (1010, 450)], fill=C["teal"])
box(280, 480, 420, 150, "H2 / MySQL", ["默认 H2 内存 · 可选 MySQL", "DataInitializer 种子数据"], C["orange"])
box(900, 480, 480, 170, "FastAPI AI 服务", ["8001 · YOLO · RAG · LLM", "失败可降级到模板规则"], C["teal"])
box(820, 720, 200, 100, "权重 pt", ["best.pt 等"], C["indigo"])
box(1040, 720, 200, 100, "知识 MD", ["高血压/肺炎…"], C["pink"])
box(1260, 720, 220, 100, "外部 LLM", ["DeepSeek 兼容"], C["green"])
d.line([(1140, 650), (920, 720)], fill=C["muted"], width=2)
d.line([(1140, 650), (1140, 720)], fill=C["muted"], width=2)
d.line([(1140, 650), (1370, 720)], fill=C["muted"], width=2)
rr(d, (40, 700, 760, 860), 14, C["white"], C["violet"], 2)
tl(d, (60, 720), "调用原则", font(18, True), C["violet"])
for i, p in enumerate(["1. 浏览器只访问业务后端", "2. AI 能力集中在 FastAPI", "3. 配置单向同步管理端→AI", "4. 失败可降级保证演示"]):
tl(d, (60, 760 + i * 24), p, font(14), C["dark"])
img.save(OUT / "architecture.png", quality=95)
def feature_panorama():
w, h = 1500, 800
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "智慧医院功能全景", font(26, True), C["dark"])
cols = [
("身份与权限", C["blue"], ["登录 / JWT", "修改密码", "角色菜单", "头像上传"]),
("业务主数据", C["purple"], ["患者管理", "用户管理", "知识库文档"]),
("诊疗业务", C["teal"], ["影像检查", "电子病历", "预约挂号", "仪表盘统计"]),
("智能能力", C["coral"], ["YOLO 影像检测", "AI 诊断报告", "EMR 决策+RAG", "AI 对话助手", "LLM/YOLO 管理"]),
]
for i, (title, col, items) in enumerate(cols):
x = 30 + i * 370
rr(d, (x, 70, x + 350, h - 30), 18, C["white"], col, 3)
rr(d, (x, 70, x + 350, 140), 18, col)
d.rectangle((x, 120, x + 350, 140), fill=col)
tc(d, (x + 175, 105), title, font(20, True), C["white"])
yy = 170
for it in items:
rr(d, (x + 20, yy, x + 330, yy + 70), 12, C["soft"])
d.ellipse((x + 40, yy + 22, x + 66, yy + 48), fill=col)
tl(d, (x + 80, yy + 22), it, font(16), C["dark"])
yy += 85
img.save(OUT / "features.png", quality=95)
def _arrow(d, x1, y1, x2, y2, fill, width=3):
"""Orthogonal-friendly straight segment with arrow head at end."""
d.line([(x1, y1), (x2, y2)], fill=fill, width=width)
# arrow head
if x2 == x1 and y2 > y1: # down
d.polygon([(x2, y2), (x2 - 7, y2 - 12), (x2 + 7, y2 - 12)], fill=fill)
elif x2 == x1 and y2 < y1: # up
d.polygon([(x2, y2), (x2 - 7, y2 + 12), (x2 + 7, y2 + 12)], fill=fill)
elif y2 == y1 and x2 > x1: # right
d.polygon([(x2, y2), (x2 - 12, y2 - 7), (x2 - 12, y2 + 7)], fill=fill)
elif y2 == y1 and x2 < x1: # left
d.polygon([(x2, y2), (x2 + 12, y2 - 7), (x2 + 12, y2 + 7)], fill=fill)
def _ortho(d, path, fill, width=3, arrow=True):
"""Draw polyline path [(x,y),...]; optional arrow on last segment."""
for i in range(len(path) - 1):
x1, y1 = path[i]
x2, y2 = path[i + 1]
if i == len(path) - 2 and arrow:
_arrow(d, x1, y1, x2, y2, fill, width)
else:
d.line([(x1, y1), (x2, y2)], fill=fill, width=width)
def imaging_flow():
"""Left-to-right pipeline with orthogonal connectors only."""
w, h = 1600, 880
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "AI 影像诊断核心流程", font(26, True), C["dark"])
# notes card top-right (no lines through it)
rr(d, (980, 50, 1560, 220), 14, C["white"], C["violet"], 2)
tl(d, (1000, 65), "技术要点", font(18, True), C["violet"])
for i, t in enumerate([
"· AsyncConfig 线程池,避免阻塞 HTTP",
"· 前端轮询有上限,卸载时清理",
"· AI 不可用 → Spring 本地规则降级",
"· 报告分段:所见 / 印象 / 建议 / 声明",
]):
tl(d, (1000, 105 + i * 28), t, font(14), C["dark"])
# Compact pipeline with guaranteed gaps between boxes
bh = 88
# (id, x, y, w, text, color)
spec = [
(0, 40, 300, 175, "新建影像\n上传图片", C["blue"]),
(1, 255, 300, 155, "PENDING", C["muted"]),
(2, 450, 300, 170, "点击AI诊断", C["orange"]),
(3, 660, 300, 170, "ANALYZING", C["coral"]),
(4, 870, 300, 170, "Spring\n@Async", C["purple"]),
(5, 1080, 300, 180, "FastAPI\nanalyze", C["teal"]),
(6, 900, 500, 180, "YOLO 检测\n标注图", C["indigo"]),
(7, 1160, 500, 180, "报告生成\nLLM/模板", C["cyan"]),
(8, 780, 700, 180, "写结果\n更新记录", C["green"]),
(9, 1020, 700, 190, "COMPLETED\n/ ERROR", C["green"]),
(10, 1270, 700, 200, "前端轮询\n报告弹窗", C["blue"]),
]
boxes = {}
for i, x, y, wbox, text, col in spec:
rr(d, (x, y, x + wbox, y + bh), 14, col)
lines = text.split("\n")
for j, ln in enumerate(lines):
tc(d, (x + wbox // 2, y + (bh // 2 - 12) + j * 26), ln, font(14, True), C["white"])
boxes[i] = (x, y, wbox, bh)
def mid_right(i):
x, y, wbox, bh_ = boxes[i]
return x + wbox, y + bh_ // 2
def mid_left(i):
x, y, wbox, bh_ = boxes[i]
return x, y + bh_ // 2
def mid_bottom(i):
x, y, wbox, bh_ = boxes[i]
return x + wbox // 2, y + bh_
def mid_top(i):
x, y, wbox, bh_ = boxes[i]
return x + wbox // 2, y
# main chain
for a, b in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]:
x1, y1 = mid_right(a)
x2, y2 = mid_left(b)
gap = x2 - x1
if gap > 16:
_arrow(d, x1 + 3, y1, x2 - 3, y2, C["slate"], 3)
# FastAPI -> split bus -> YOLO / Report
fx, fy = mid_bottom(5)
bus_y = 450
d.line([(fx, fy), (fx, bus_y)], fill=C["slate"], width=3)
yx, _ = mid_top(6)
rx, _ = mid_top(7)
d.line([(min(yx, fx), bus_y), (max(rx, fx), bus_y)], fill=C["slate"], width=3)
_arrow(d, yx, bus_y, yx, mid_top(6)[1] - 2, C["slate"], 3)
_arrow(d, rx, bus_y, rx, mid_top(7)[1] - 2, C["slate"], 3)
# merge YOLO + Report -> 写结果
merge_y = 640
d.line([(mid_bottom(6)[0], mid_bottom(6)[1]), (mid_bottom(6)[0], merge_y)], fill=C["slate"], width=3)
d.line([(mid_bottom(7)[0], mid_bottom(7)[1]), (mid_bottom(7)[0], merge_y)], fill=C["slate"], width=3)
d.line([(mid_top(8)[0], merge_y), (mid_bottom(7)[0], merge_y)], fill=C["slate"], width=3)
_arrow(d, mid_top(8)[0], merge_y, mid_top(8)[0], mid_top(8)[1] - 2, C["slate"], 3)
# bottom chain
for a, b in [(8, 9), (9, 10)]:
x1, y1 = mid_right(a)
x2, y2 = mid_left(b)
if x2 - x1 > 12:
_arrow(d, x1 + 3, y1, x2 - 3, y2, C["slate"], 3)
img.save(OUT / "imaging_flow.png", quality=95)
def decision_flow():
w, h = 1500, 500
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "电子病历辅助决策流程", font(24, True), C["dark"])
nodes = [
("保存病历", C["blue"]),
("Decision\nSupport", C["purple"]),
("FastAPI\n/report/decision", C["teal"]),
("RAG+LLM\n或模板回退", C["orange"]),
("七类建议\nDialog 展示", C["coral"]),
]
boxes = []
for i, (t, col) in enumerate(nodes):
x = 40 + i * 290
rr(d, (x, 70, x + 250, 200), 16, col)
for j, ln in enumerate(t.split("\n")):
tc(d, (x + 125, 120 + j * 30), ln, font(16, True), C["white"])
boxes.append((x, 70, 250, 130))
if i < 4:
# edge-to-edge arrow in the gap only
x1 = x + 250 + 6
x2 = x + 290 - 6
midy = 70 + 65
_arrow(d, x1, midy, x2, midy, C["slate"], 3)
# vertical from last node down to output row bus
last = boxes[-1]
lx = last[0] + last[2] // 2
d.line([(lx, last[1] + last[3]), (lx, 300)], fill=C["slate"], width=3)
d.line([(50, 300), (1450, 300)], fill=C["slate"], width=3)
outs = [("治疗", C["blue"]), ("用药", C["purple"]), ("护理", C["teal"]), ("随访", C["green"]),
("风险", C["orange"]), ("冲突", C["coral"]), ("RAG", C["indigo"])]
for i, (o, col) in enumerate(outs):
x = 50 + i * 205
_arrow(d, x + 90, 300, x + 90, 330, C["slate"], 2)
rr(d, (x, 335, x + 180, 450), 12, col)
tc(d, (x + 90, 392), o, font(16, True), C["white"])
img.save(OUT / "decision.png", quality=95)
def state_machine():
w, h = 1400, 280
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "影像诊断状态机", font(22, True), C["dark"])
states = [
("PENDING", "已登记待诊断", C["muted"]),
("ANALYZING", "诊断进行中", C["orange"]),
("COMPLETED", "成功可看报告", C["green"]),
("ERROR", "失败", C["coral"]),
]
for i, (n, desc, col) in enumerate(states):
x = 50 + i * 340
rr(d, (x, 70, x + 280, 220), 18, col)
tc(d, (x + 140, 120), n, font(20, True), C["white"])
tc(d, (x + 140, 170), desc, font(14), C["white"])
if i < 3:
d.polygon([(x + 290, 140), (x + 325, 130), (x + 325, 150)], fill=C["slate"])
img.save(OUT / "state.png", quality=95)
def yolo_concept():
w, h = 1000, 560
img = Image.new("RGB", (w, h), (20, 28, 48))
d = ImageDraw.Draw(img)
tc(d, (250, 30), "原始影像(示意)", font(18, True), C["cyan"])
tc(d, (750, 30), "YOLO 标注(示意)", font(18, True), C["coral"])
rr(d, (40, 60, 460, 500), 12, (30, 40, 60))
rr(d, (540, 60, 960, 500), 12, (30, 40, 60))
d.ellipse((90, 120, 220, 400), outline=(160, 190, 210), width=2)
d.ellipse((250, 120, 380, 400), outline=(160, 190, 210), width=2)
d.ellipse((590, 120, 720, 400), outline=(160, 190, 210), width=2)
d.ellipse((750, 120, 880, 400), outline=(160, 190, 210), width=2)
d.rectangle((600, 180, 700, 270), outline=C["teal"], width=3)
tl(d, (600, 155), "findings 0.87", font(14), C["teal"])
d.rectangle((780, 250, 870, 330), outline=C["orange"], width=3)
tl(d, (760, 225), "nodule 0.76", font(14), C["orange"])
tl(d, (40, 520), "仅教学演示概念图,非真实患者数据", font(13), C["muted"])
img.save(OUT / "yolo.png", quality=95)
def team_overview():
"""Six members in 2x3 grid — orthogonal tree, no diagonals through cards."""
w, h = 1500, 740
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 12), "六人协作总览(按工程模块划分)", font(26, True), C["dark"])
# hub
hub = (450, 50, 1050, 115)
rr(d, hub, 16, C["navy"])
tc(d, (750, 82), "Smart Hospital · 6 人三端协作", font(20, True), C["white"])
# column centers and card geometry
cols_x = [40, 520, 1000] # left of cards
card_w, card_h = 460, 200
top_y, bot_y = 175, 460
cxs = [x + card_w // 2 for x in cols_x] # 270, 750, 1230
# tree connectors (draw first, under cards visually by being outside card bodies)
# hub bottom center down to bus
bus_y = 145
d.line([(750, 115), (750, bus_y)], fill=C["slate"], width=3)
d.line([(cxs[0], bus_y), (cxs[2], bus_y)], fill=C["slate"], width=3)
for cx in cxs:
d.line([(cx, bus_y), (cx, top_y)], fill=C["slate"], width=3)
# vertical between top and bottom cards in each column
for cx, x in zip(cxs, cols_x):
d.line([(cx, top_y + card_h), (cx, bot_y)], fill=C["slate"], width=3)
members = [
(cols_x[0], top_y, "成员A", "前端基础", "布局 · 登录 · 权限 · Pinia", C["blue"], "前端层"),
(cols_x[1], top_y, "成员B", "前端业务", "仪表盘 · 患者360° · 预约", C["cyan"], "前端层"),
(cols_x[2], top_y, "成员C", "前端智能", "影像 · 病历决策 · AI助手", C["purple"], "前端层"),
(cols_x[0], bot_y, "成员D", "后端主数据", "JWT · 患者 · 用户 · 预约", C["orange"], "业务后端"),
(cols_x[1], bot_y, "成员E", "后端 AI 桥", "异步诊断 · Client · 降级", C["coral"], "业务后端"),
(cols_x[2], bot_y, "成员F", "AI 微服务", "YOLO · RAG · LLM · 权重", C["teal"], "AI 服务"),
]
for x, y, name, role, desc, col, layer in members:
rr(d, (x, y, x + card_w, y + card_h), 18, C["white"], col, 3)
rr(d, (x, y, x + card_w, y + 58), 18, col)
d.rectangle((x, y + 42, x + card_w, y + 58), fill=col)
tc(d, (x + card_w // 2, y + 30), f"{name} · {role}", font(17, True), C["white"])
tc(d, (x + card_w // 2, y + 105), desc, font(15), C["dark"])
rr(d, (x + 140, y + 145, x + 320, y + 178), 10, col)
tc(d, (x + card_w // 2, y + 161), layer, font(13, True), C["white"])
tl(d, (40, 690), "树状正交连线:总枢纽 → 三列 → 上下两排;连线只走卡片外空隙,不穿过文字", font(14), C["muted"])
img.save(OUT / "team.png", quality=95)
def team_swimlane():
"""Wide short swimlane (fits full PPT width without looking tiny)."""
w, h = 1600, 360 # ~4.4:1 — scales to full slide width
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (20, 8), "六人分工泳道 · 交付物对照(全宽)", font(20, True), C["dark"])
rows = [
("A 前端基础", C["blue"], "BasicLayout · 登录 · 路由守卫 · Pinia · 个人中心"),
("B 前端业务", C["cyan"], "Dashboard · Patients 360° · Appointments · ECharts"),
("C 前端智能", C["purple"], "Imaging 报告弹窗 · EMR 决策 Dialog · AI 助手 · 知识库页"),
("D 后端主数据", C["orange"], "Security/JWT · User · Patient · Appointment · Result"),
("E 后端 AI 桥", C["coral"], "AIDiagnosis 异步 · AiServiceClient · 决策对接 · 降级"),
("F AI 微服务", C["teal"], "YOLO · /imaging/analyze · RAG · LLM · 权重/配置"),
]
row_h, gap, top = 48, 6, 42
for i, (name, col, desc) in enumerate(rows):
y = top + i * (row_h + gap)
rr(d, (20, y, 280, y + row_h), 10, col)
tc(d, (150, y + row_h // 2), name, font(15, True), C["white"])
rr(d, (295, y, 1580, y + row_h), 10, C["white"], col, 2)
tl(d, (315, y + 12), desc, font(15), C["dark"])
img.save(OUT / "team_lane.png", quality=95)
def module_tree():
w, h = 1400, 620
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "仓库三工程结构", font(24, True), C["dark"])
roots = [
(40, "frontend/", C["blue"], ["api/ HTTP", "views/ 页面", "router 守卫", "stores Pinia"]),
(480, "smart-hospital/", C["purple"], ["controller/service", "security JWT", "uploads 文件", "data/ai 配置"]),
(920, "ai-service/", C["teal"], ["api imaging/rag", "yolo · llm", "knowledge MD", "weights pt"]),
]
for x, title, col, items in roots:
rr(d, (x, 70, x + 400, 580), 18, C["white"], col, 3)
rr(d, (x, 70, x + 400, 140), 18, col)
d.rectangle((x, 120, x + 400, 140), fill=col)
tc(d, (x + 200, 105), title, font(20, True), C["white"])
yy = 170
for it in items:
rr(d, (x + 30, yy, x + 370, yy + 80), 12, C["soft"])
tl(d, (x + 55, yy + 25), it, font(16), C["dark"])
yy += 95
img.save(OUT / "modules.png", quality=95)
def demo_path():
w, h = 1500, 300
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (20, 10), "推荐演示剧本(7 步)", font(20, True), C["dark"])
steps = [
("1", "doctor1\n登录", C["blue"]),
("2", "患者\n360°", C["cyan"]),
("3", "影像\nAI诊断", C["purple"]),
("4", "病历\n决策", C["teal"]),
("5", "预约\n流转", C["orange"]),
("6", "admin\n配置", C["coral"]),
("7", "降级\n验证", C["indigo"]),
]
for i, (n, t, col) in enumerate(steps):
x = 40 + i * 210
d.ellipse((x + 55, 55, x + 115, 115), fill=col)
tc(d, (x + 85, 85), n, font(20, True), C["white"])
for j, ln in enumerate(t.split("\n")):
tc(d, (x + 85, 140 + j * 28), ln, font(14), C["dark"])
if i < 6:
d.line([(x + 125, 85), (x + 195, 85)], fill=C["muted"], width=3)
img.save(OUT / "demo.png", quality=95)
def closing():
w, h = 1920, 1080
img = gradient(w, h, (30, 20, 80), (10, 90, 120))
ov = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(ov)
for i, col in enumerate([C["cyan"], C["purple"], C["coral"], C["teal"], C["orange"], C["blue"]]):
ang = i * 60
import math
cx = 960 + int(280 * math.cos(math.radians(ang)))
cy = 480 + int(200 * math.sin(math.radians(ang)))
d.ellipse((cx - 40, cy - 40, cx + 40, cy + 40), fill=(*col, 160))
d.ellipse((860, 380, 1060, 580), outline=(*C["cyan"], 180), width=5)
d.rounded_rectangle((940, 430, 980, 530), radius=6, fill=(*C["teal"], 220))
d.rounded_rectangle((910, 460, 1010, 500), radius=6, fill=(*C["teal"], 220))
img = Image.alpha_composite(img.convert("RGBA"), ov).convert("RGB")
img.save(OUT / "closing.png", quality=95)
def nfr_icons():
w, h = 1500, 280
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
items = [
("性能", "@Async 异步\n有限轮询", C["blue"]),
("可用性", "H2 开箱\n种子数据", C["teal"]),
("安全", "JWT·BCrypt\nCORS", C["purple"]),
("可维护", "Result<T>\n全局异常", C["orange"]),
("可扩展", "AI 独立\n进程解耦", C["coral"]),
("兼容", "OpenAI\n协议可切换", C["indigo"]),
]
for i, (t, desc, col) in enumerate(items):
x = 20 + i * 245
rr(d, (x, 25, x + 230, 250), 16, C["white"], col, 3)
rr(d, (x, 25, x + 230, 90), 16, col)
d.rectangle((x, 70, x + 230, 90), fill=col)
tc(d, (x + 115, 55), t, font(18, True), C["white"])
for j, ln in enumerate(desc.split("\n")):
tc(d, (x + 115, 130 + j * 35), ln, font(14), C["dark"])
img.save(OUT / "nfr.png", quality=95)
def er_mini():
"""
Clean ER with orthogonal bus connectors — lines never cross through card text.
Layout (文档 8.1 / 8.4):
User
______|______
| | |
Imaging EMR Appointment
| | |
+------+------+
|
Patient
Imaging
|
AIResult
"""
w, h = 1200, 640
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (24, 12), "核心实体关系(正交连线)", font(22, True), C["dark"])
def ent(x, y, bw, title, fields, col):
bh = 44 + 22 * len(fields) + 14
rr(d, (x, y, x + bw, y + bh), 12, C["white"], col, 2)
rr(d, (x, y, x + bw, y + 40), 12, col)
d.rectangle((x, y + 28, x + bw, y + 40), fill=col)
tc(d, (x + bw // 2, y + 20), title, font(16, True), C["white"])
yy = y + 52
for f in fields:
tl(d, (x + 16, yy), f, font(13), C["dark"])
yy += 22
# return box edges
return {"x": x, "y": y, "w": bw, "h": bh, "cx": x + bw // 2, "cy": y + bh // 2,
"top": (x + bw // 2, y), "bottom": (x + bw // 2, y + bh),
"left": (x, y + bh // 2), "right": (x + bw, y + bh // 2)}
BW = 220
user = ent(490, 50, BW, "User", ["ADMIN/DOCTOR/RADIO", "BCrypt 密码"], C["purple"])
imaging = ent(120, 250, BW, "Imaging", ["类型/状态", "图像 URL"], C["teal"])
emr = ent(490, 250, BW, "EMR", ["主诉→随访", "决策关联"], C["orange"])
appt = ent(860, 250, BW, "Appointment", ["状态机", "按日筛选"], C["indigo"])
patient = ent(490, 450, BW, "Patient", ["档案字段", "既往史"], C["blue"])
ai = ent(120, 450, BW, "AIResult", ["置信度/检测框", "完整报告"], C["coral"])
line_c = C["slate"]
# User bottom -> horizontal bus -> drop to each mid entity top
bus_y = 200
ux, uy = user["bottom"]
d.line([(ux, uy), (ux, bus_y)], fill=line_c, width=3)
# bus spans imaging.cx to appt.cx
d.line([(imaging["cx"], bus_y), (appt["cx"], bus_y)], fill=line_c, width=3)
for box in (imaging, emr, appt):
_arrow(d, box["cx"], bus_y, box["cx"], box["top"][1] - 2, line_c, 3)
# Mid entities bottom -> lower bus -> Patient top
bus2_y = 400
for box in (imaging, emr, appt):
d.line([(box["cx"], box["bottom"][1]), (box["cx"], bus2_y)], fill=line_c, width=3)
d.line([(imaging["cx"], bus2_y), (appt["cx"], bus2_y)], fill=line_c, width=3)
_arrow(d, patient["cx"], bus2_y, patient["cx"], patient["top"][1] - 2, line_c, 3)
# Imaging → AIResult: continuous vertical (bus2 crosses same x — OK for tree)
ix = imaging["cx"]
d.line([(ix, imaging["bottom"][1]), (ix, ai["top"][1] - 2)], fill=line_c, width=3)
_arrow(d, ix, ai["top"][1] - 14, ix, ai["top"][1] - 2, line_c, 3)
# 1:1 label placed to the LEFT of the stem, clear of bus
tl(d, (ix - 48, (imaging["bottom"][1] + bus2_y) // 2 - 6), "1:1", font(12, True), C["coral"])
# relationship captions on buses (right side, clear of arrows)
tl(d, (740, 178), "医生创建", font(12), C["muted"])
tl(d, (740, 378), "归属患者", font(12), C["muted"])
# legend
rr(d, (860, 480, 1160, 600), 12, C["white"], C["soft"], 2)
tl(d, (880, 495), "关系说明", font(14, True), C["dark"])
tl(d, (880, 525), "User → 影像/病历/预约", font(12), C["muted"])
tl(d, (880, 550), "Patient ← 三类业务记录", font(12), C["muted"])
tl(d, (880, 575), "Imaging → AIResult (1:1)", font(12), C["muted"])
img.save(OUT / "er.png", quality=95)
if __name__ == "__main__":
cover()
dual_chain()
goals_radar()
scenes()
pain_transform()
priority()
tech_stack()
architecture()
feature_panorama()
imaging_flow()
decision_flow()
state_machine()
yolo_concept()
team_overview()
team_swimlane()
module_tree()
demo_path()
closing()
nfr_icons()
er_mini()
print("assets_v2 done ->", OUT)