723 lines
27 KiB
Python
723 lines
27 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Generate diagram assets for Smart Hospital PPT (medical tech blue)."""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
|
|
|
OUT = Path(__file__).resolve().parent / "assets"
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Palette
|
|
NAVY = (11, 58, 92)
|
|
TEAL = (26, 122, 156)
|
|
ACCENT = (43, 187, 173)
|
|
LIGHT = (244, 248, 251)
|
|
WHITE = (255, 255, 255)
|
|
DARK = (30, 41, 59)
|
|
MUTED = (100, 116, 139)
|
|
SOFT = (226, 236, 244)
|
|
ORANGE = (245, 158, 11)
|
|
GREEN = (34, 197, 94)
|
|
RED = (239, 68, 68)
|
|
PURPLE = (99, 102, 241)
|
|
|
|
|
|
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
|
candidates = [
|
|
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\simsun.ttc",
|
|
r"C:\Windows\Fonts\arial.ttf",
|
|
]
|
|
for p in candidates:
|
|
try:
|
|
return ImageFont.truetype(p, size=size)
|
|
except OSError:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def round_rect(draw, xy, r, fill, outline=None, width=1):
|
|
draw.rounded_rectangle(xy, radius=r, fill=fill, outline=outline, width=width)
|
|
|
|
|
|
def text_center(draw, xy, text, fnt, fill=WHITE):
|
|
bbox = draw.textbbox((0, 0), text, font=fnt)
|
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
x = xy[0] - tw // 2
|
|
y = xy[1] - th // 2
|
|
draw.text((x, y), text, font=fnt, fill=fill)
|
|
|
|
|
|
def text_left(draw, xy, text, fnt, fill=DARK):
|
|
draw.text(xy, text, font=fnt, fill=fill)
|
|
|
|
|
|
def gradient_bg(w, h, c1=NAVY, c2=TEAL):
|
|
img = Image.new("RGB", (w, h), c1)
|
|
px = img.load()
|
|
for y in range(h):
|
|
t = y / max(h - 1, 1)
|
|
r = int(c1[0] * (1 - t) + c2[0] * t)
|
|
g = int(c1[1] * (1 - t) + c2[1] * t)
|
|
b = int(c1[2] * (1 - t) + c2[2] * t)
|
|
for x in range(w):
|
|
# subtle radial-ish variation
|
|
edge = abs(x - w / 2) / (w / 2)
|
|
k = 0.12 * edge
|
|
px[x, y] = (
|
|
max(0, min(255, int(r * (1 - k)))),
|
|
max(0, min(255, int(g * (1 - k)))),
|
|
max(0, min(255, int(b * (1 - k)))),
|
|
)
|
|
return img
|
|
|
|
|
|
def draw_grid_dots(draw, w, h, step=40, color=(255, 255, 255, 40)):
|
|
for x in range(0, w, step):
|
|
for y in range(0, h, step):
|
|
draw.ellipse((x, y, x + 2, y + 2), fill=color[:3])
|
|
|
|
|
|
def cover_bg():
|
|
w, h = 1920, 1080
|
|
img = gradient_bg(w, h, NAVY, (8, 90, 110))
|
|
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
d = ImageDraw.Draw(overlay)
|
|
# abstract circles / network
|
|
for i, (cx, cy, r) in enumerate(
|
|
[(300, 200, 180), (1600, 250, 220), (1400, 850, 260), (200, 900, 150), (960, 540, 320)]
|
|
):
|
|
alpha = 28 + (i % 3) * 10
|
|
d.ellipse((cx - r, cy - r, cx + r, cy + r), outline=(*ACCENT, alpha), width=3)
|
|
# connecting lines
|
|
pts = [(320, 260), (700, 400), (1100, 320), (1500, 500), (1200, 700), (600, 720)]
|
|
for i in range(len(pts) - 1):
|
|
d.line([pts[i], pts[i + 1]], fill=(*ACCENT, 70), width=2)
|
|
for p in pts:
|
|
d.ellipse((p[0] - 8, p[1] - 8, p[0] + 8, p[1] + 8), fill=(*ACCENT, 160))
|
|
# soft panel
|
|
d.rounded_rectangle((120, 180, 1800, 900), radius=28, fill=(11, 40, 70, 120), outline=(*ACCENT, 90), width=2)
|
|
# medical cross abstract
|
|
cx, cy = 1600, 780
|
|
d.rounded_rectangle((cx - 18, cy - 55, cx + 18, cy + 55), radius=6, fill=(*ACCENT, 180))
|
|
d.rounded_rectangle((cx - 55, cy - 18, cx + 55, cy + 18), radius=6, fill=(*ACCENT, 180))
|
|
img = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
|
|
img.save(OUT / "cover_bg.png", quality=95)
|
|
print("cover_bg.png")
|
|
|
|
|
|
def dual_chain():
|
|
w, h = 1400, 720
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_title = font(28, True)
|
|
f_node = font(20, True)
|
|
f_sub = font(16)
|
|
# title
|
|
text_left(d, (40, 24), "双核心业务链路", f_title, NAVY)
|
|
|
|
def chain(y, title, color, nodes):
|
|
round_rect(d, (40, y, w - 40, y + 280), 18, WHITE, SOFT, 2)
|
|
d.rectangle((40, y, 52, y + 280), fill=color)
|
|
text_left(d, (70, y + 16), title, f_title, color)
|
|
n = len(nodes)
|
|
box_w, box_h = 220, 100
|
|
gap = (w - 120 - n * box_w) // max(n - 1, 1)
|
|
xs = []
|
|
for i, (t1, t2) in enumerate(nodes):
|
|
x = 70 + i * (box_w + gap)
|
|
xs.append(x + box_w // 2)
|
|
round_rect(d, (x, y + 90, x + box_w, y + 90 + box_h), 14, color, None)
|
|
text_center(d, (x + box_w // 2, y + 90 + 38), t1, f_node, WHITE)
|
|
text_center(d, (x + box_w // 2, y + 90 + 68), t2, f_sub, (230, 250, 248))
|
|
if i < n - 1:
|
|
x1 = x + box_w + 8
|
|
x2 = x + box_w + gap - 8
|
|
midy = y + 90 + box_h // 2
|
|
d.line([(x1, midy), (x2, midy)], fill=color, width=3)
|
|
d.polygon([(x2, midy), (x2 - 12, midy - 7), (x2 - 12, midy + 7)], fill=color)
|
|
|
|
chain(
|
|
70,
|
|
"链路 A · 医学影像 AI 读片",
|
|
TEAL,
|
|
[("影像检查", "上传 X光/CT/MRI/超声"), ("YOLO 检测", "标注框 + 置信度"), ("结构化报告", "所见/印象/建议")],
|
|
)
|
|
chain(
|
|
390,
|
|
"链路 B · 电子病历辅助决策",
|
|
ACCENT,
|
|
[("病历录入", "主诉→诊断→用药"), ("RAG + LLM", "知识检索与生成"), ("决策建议", "治疗/护理/随访")],
|
|
)
|
|
img.save(OUT / "dual_chain.png", quality=95)
|
|
print("dual_chain.png")
|
|
|
|
|
|
def feature_panorama():
|
|
w, h = 1500, 820
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_h = font(30, True)
|
|
f_c = font(22, True)
|
|
f_i = font(18)
|
|
text_left(d, (40, 24), "智慧医院功能全景", f_h, NAVY)
|
|
cols = [
|
|
("身份与权限", NAVY, ["登录 / JWT", "修改密码", "角色菜单", "头像上传"]),
|
|
("业务主数据", TEAL, ["患者管理", "用户管理", "知识库文档"]),
|
|
("诊疗业务", ACCENT, ["影像检查", "电子病历", "预约挂号", "仪表盘统计"]),
|
|
("智能能力", PURPLE, ["YOLO 影像检测", "AI 诊断报告", "EMR 决策 + RAG", "AI 对话助手", "LLM / YOLO 管理"]),
|
|
]
|
|
cw = 340
|
|
gap = 20
|
|
x0 = 40
|
|
for i, (title, color, items) in enumerate(cols):
|
|
x = x0 + i * (cw + gap)
|
|
round_rect(d, (x, 90, x + cw, h - 40), 16, WHITE, SOFT, 2)
|
|
round_rect(d, (x, 90, x + cw, 160), 16, color)
|
|
# fix bottom of header
|
|
d.rectangle((x, 140, x + cw, 160), fill=color)
|
|
text_center(d, (x + cw // 2, 125), title, f_c, WHITE)
|
|
yy = 190
|
|
for it in items:
|
|
round_rect(d, (x + 20, yy, x + cw - 20, yy + 48), 10, SOFT)
|
|
d.ellipse((x + 36, yy + 16, x + 52, yy + 32), fill=color)
|
|
text_left(d, (x + 66, yy + 12), it, f_i, DARK)
|
|
yy += 58
|
|
img.save(OUT / "feature_panorama.png", quality=95)
|
|
print("feature_panorama.png")
|
|
|
|
|
|
def architecture():
|
|
w, h = 1600, 900
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_h = font(28, True)
|
|
f_b = font(20, True)
|
|
f_s = font(16)
|
|
text_left(d, (40, 20), "系统逻辑架构", f_h, NAVY)
|
|
|
|
def box(x, y, bw, bh, title, lines, color):
|
|
round_rect(d, (x, y, x + bw, y + bh), 14, WHITE, color, 3)
|
|
round_rect(d, (x, y, x + bw, y + 44), 14, color)
|
|
d.rectangle((x, y + 28, x + bw, y + 44), fill=color)
|
|
text_center(d, (x + bw // 2, y + 22), title, f_b, WHITE)
|
|
yy = y + 58
|
|
for ln in lines:
|
|
text_center(d, (x + bw // 2, yy), ln, f_s, DARK)
|
|
yy += 26
|
|
|
|
# top browser
|
|
box(560, 70, 480, 120, "浏览器 Vue SPA", ["localhost:5173", "Element Plus · Pinia · ECharts"], TEAL)
|
|
# arrow down
|
|
d.line([(800, 190), (800, 240)], fill=NAVY, width=3)
|
|
d.polygon([(800, 250), (790, 235), (810, 235)], fill=NAVY)
|
|
text_left(d, (820, 205), "/api Vite 代理", f_s, MUTED)
|
|
|
|
box(480, 260, 640, 140, "Spring Boot 业务端", ["localhost:8080 · JWT / JPA / 文件存储", "鉴权 · 落库 · 任务状态 · 降级"], NAVY)
|
|
|
|
# split arrows
|
|
d.line([(640, 400), (640, 470)], fill=TEAL, width=3)
|
|
d.line([(1000, 400), (1000, 470)], fill=ACCENT, width=3)
|
|
d.line([(640, 470), (1000, 470)], fill=MUTED, width=2)
|
|
d.polygon([(640, 485), (630, 470), (650, 470)], fill=TEAL)
|
|
d.polygon([(1000, 485), (990, 470), (1010, 470)], fill=ACCENT)
|
|
|
|
box(280, 500, 420, 160, "H2 / MySQL 业务库", ["默认 H2 内存 · 可选 MySQL", "DataInitializer 种子数据"], TEAL)
|
|
box(900, 500, 480, 180, "FastAPI AI 服务", ["localhost:8001", "YOLO · RAG · LLM 报告/决策", "失败可降级到模板规则"], ACCENT)
|
|
|
|
# bottom leaves
|
|
box(820, 720, 200, 100, "本地权重", ["best.pt 等"], PURPLE)
|
|
box(1040, 720, 200, 100, "知识 Markdown", ["高血压/肺炎…"], ORANGE)
|
|
box(1260, 720, 220, 100, "外部 LLM API", ["DeepSeek 兼容"], GREEN)
|
|
d.line([(1140, 680), (920, 720)], fill=MUTED, width=2)
|
|
d.line([(1140, 680), (1140, 720)], fill=MUTED, width=2)
|
|
d.line([(1140, 680), (1370, 720)], fill=MUTED, width=2)
|
|
|
|
# principles strip
|
|
round_rect(d, (40, 720, 760, 860), 12, WHITE, SOFT, 2)
|
|
text_left(d, (60, 735), "调用原则", f_b, NAVY)
|
|
principles = [
|
|
"1. 浏览器只访问业务后端(经代理)",
|
|
"2. AI 能力集中在 FastAPI",
|
|
"3. 配置单向同步:管理端 → AI 运行时",
|
|
"4. 失败可降级:超时/宕机仍可演示",
|
|
]
|
|
yy = 770
|
|
for p in principles:
|
|
text_left(d, (60, yy), p, f_s, DARK)
|
|
yy += 22
|
|
|
|
img.save(OUT / "architecture.png", quality=95)
|
|
print("architecture.png")
|
|
|
|
|
|
def imaging_flow():
|
|
w, h = 1600, 900
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_h = font(28, True)
|
|
f_n = font(18, True)
|
|
f_s = font(15)
|
|
text_left(d, (40, 20), "核心流程 · AI 影像诊断", f_h, NAVY)
|
|
|
|
steps = [
|
|
(80, 100, "新建影像记录\n上传图片", TEAL),
|
|
(80, 250, "状态 PENDING", MUTED),
|
|
(80, 400, "点击「AI 诊断」", ORANGE),
|
|
(80, 550, "状态 ANALYZING", ORANGE),
|
|
(420, 550, "Spring\nAIDiagnosisService\n@Async", NAVY),
|
|
(760, 550, "FastAPI\n/imaging/analyze", ACCENT),
|
|
(1100, 450, "YOLO 检测\n绘制标注图", PURPLE),
|
|
(1100, 620, "报告生成\nLLM 或模板", TEAL),
|
|
(1400, 520, "写结果\n更新记录", GREEN),
|
|
(1400, 250, "COMPLETED\n/ ERROR", GREEN),
|
|
(1100, 120, "前端轮询\n报告弹窗", TEAL),
|
|
]
|
|
# draw boxes
|
|
positions = {}
|
|
for i, (x, y, text, color) in enumerate(steps):
|
|
bw, bh = 220, 90
|
|
if "\n" in text and text.count("\n") >= 2:
|
|
bh = 110
|
|
round_rect(d, (x, y, x + bw, y + bh), 12, color)
|
|
lines = text.split("\n")
|
|
for j, ln in enumerate(lines):
|
|
text_center(d, (x + bw // 2, y + 22 + j * 24), ln, f_n if j == 0 else f_s, WHITE)
|
|
positions[i] = (x + bw // 2, y + bh // 2, x, y, bw, bh)
|
|
|
|
def arrow(a, b):
|
|
x1, y1 = positions[a][0], positions[a][1]
|
|
x2, y2 = positions[b][0], positions[b][1]
|
|
# adjust to box edges roughly
|
|
d.line([(x1, y1), (x2, y2)], fill=NAVY, width=2)
|
|
ang = math.atan2(y2 - y1, x2 - x1)
|
|
ax, ay = x2 - 18 * math.cos(ang), y2 - 18 * math.sin(ang)
|
|
d.polygon(
|
|
[
|
|
(ax, ay),
|
|
(ax - 10 * math.cos(ang - 0.4), ay - 10 * math.sin(ang - 0.4)),
|
|
(ax - 10 * math.cos(ang + 0.4), ay - 10 * math.sin(ang + 0.4)),
|
|
],
|
|
fill=NAVY,
|
|
)
|
|
|
|
for a, b in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (5, 7), (6, 8), (7, 8), (8, 9), (9, 10)]:
|
|
arrow(a, b)
|
|
|
|
# note
|
|
round_rect(d, (420, 100, 1000, 220), 12, WHITE, SOFT, 2)
|
|
text_left(d, (440, 115), "技术要点", f_n, NAVY)
|
|
notes = [
|
|
"· 异步诊断:AsyncConfig 线程池,避免阻塞 HTTP",
|
|
"· 前端轮询有上限,组件卸载时清理定时器",
|
|
"· AI 不可用时 Spring 本地规则降级,演示不断链",
|
|
"· 报告分段:所见 / 印象 / 建议 / 声明",
|
|
]
|
|
yy = 150
|
|
for n in notes:
|
|
text_left(d, (440, yy), n, f_s, DARK)
|
|
yy += 22
|
|
|
|
img.save(OUT / "imaging_flow.png", quality=95)
|
|
print("imaging_flow.png")
|
|
|
|
|
|
def state_machine():
|
|
w, h = 1100, 320
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_n = font(20, True)
|
|
f_s = font(14)
|
|
states = [
|
|
("PENDING", "已登记,待诊断", MUTED),
|
|
("ANALYZING", "诊断进行中", ORANGE),
|
|
("COMPLETED", "成功,可看报告", GREEN),
|
|
("ERROR", "失败", RED),
|
|
]
|
|
for i, (name, desc, color) in enumerate(states):
|
|
x = 40 + i * 270
|
|
round_rect(d, (x, 80, x + 200, 200), 16, color)
|
|
text_center(d, (x + 100, 130), name, f_n, WHITE)
|
|
text_center(d, (x + 100, 170), desc, f_s, WHITE)
|
|
if i < 3:
|
|
d.line([(x + 210, 140), (x + 255, 140)], fill=NAVY, width=3)
|
|
d.polygon([(x + 260, 140), (x + 248, 132), (x + 248, 148)], fill=NAVY)
|
|
# branch note from analyzing
|
|
text_left(d, (40, 30), "影像诊断状态机", font(24, True), NAVY)
|
|
text_left(d, (580, 250), "ANALYZING 后分支到 COMPLETED 或 ERROR", f_s, MUTED)
|
|
img.save(OUT / "state_machine.png", quality=95)
|
|
print("state_machine.png")
|
|
|
|
|
|
def er_diagram():
|
|
w, h = 1200, 700
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_h = font(26, True)
|
|
f_n = font(18, True)
|
|
f_s = font(14)
|
|
text_left(d, (30, 20), "主要实体关系", f_h, NAVY)
|
|
|
|
def ent(x, y, title, fields, color):
|
|
bw = 240
|
|
bh = 40 + 22 * len(fields) + 16
|
|
round_rect(d, (x, y, x + bw, y + bh), 12, WHITE, color, 2)
|
|
round_rect(d, (x, y, x + bw, y + 40), 12, color)
|
|
d.rectangle((x, y + 26, x + bw, y + 40), fill=color)
|
|
text_center(d, (x + bw // 2, y + 20), title, f_n, WHITE)
|
|
yy = y + 52
|
|
for f in fields:
|
|
text_left(d, (x + 16, yy), f, f_s, DARK)
|
|
yy += 22
|
|
return x + bw // 2, y + bh // 2, x, y, bw, bh
|
|
|
|
u = ent(480, 60, "User", ["ADMIN/DOCTOR/RADIOLOGIST", "BCrypt 密码 · 科室"], NAVY)
|
|
p = ent(40, 280, "Patient", ["姓名/性别/年龄", "证件/联系/既往史"], TEAL)
|
|
im = ent(360, 280, "ImagingRecord", ["类型/部位/图像", "状态机 PENDING…"], ACCENT)
|
|
ai = ent(360, 520, "AIDiagnosisResult", ["置信度/所见/建议", "检测JSON/标注图"], PURPLE)
|
|
em = ent(700, 280, "ElectronicMedicalRecord", ["主诉→随访全字段", "关联患者/医生"], TEAL)
|
|
ap = ent(960, 280, "Appointment", ["预约日/科室", "状态流转"], ORANGE)
|
|
|
|
def link(a, b, label=""):
|
|
d.line([(a[0], a[1]), (b[0], b[1])], fill=MUTED, width=2)
|
|
if label:
|
|
mx, my = (a[0] + b[0]) // 2, (a[1] + b[1]) // 2
|
|
text_left(d, (mx + 4, my - 10), label, f_s, MUTED)
|
|
|
|
link(u, im)
|
|
link(u, em)
|
|
link(u, ap)
|
|
link(p, im)
|
|
link(p, em)
|
|
link(p, ap)
|
|
link(im, ai, "1:1")
|
|
|
|
img.save(OUT / "er_diagram.png", quality=95)
|
|
print("er_diagram.png")
|
|
|
|
|
|
def llm_sync():
|
|
w, h = 1200, 280
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_n = font(18, True)
|
|
f_s = font(14)
|
|
text_left(d, (30, 16), "LLM 配置同步链路", font(22, True), NAVY)
|
|
nodes = [
|
|
("管理员\nAI 配置保存", NAVY),
|
|
("./data/ai/\nsettings.json", TEAL),
|
|
("AI 服务\n/llm-config", ACCENT),
|
|
("FastAPI 运行时\n启用大模型", GREEN),
|
|
]
|
|
for i, (t, c) in enumerate(nodes):
|
|
x = 40 + i * 290
|
|
round_rect(d, (x, 70, x + 230, 180), 14, c)
|
|
lines = t.split("\n")
|
|
for j, ln in enumerate(lines):
|
|
text_center(d, (x + 115, 110 + j * 28), ln, f_n, WHITE)
|
|
if i < 3:
|
|
d.line([(x + 240, 125), (x + 275, 125)], fill=NAVY, width=3)
|
|
d.polygon([(x + 280, 125), (x + 268, 117), (x + 268, 133)], fill=NAVY)
|
|
text_left(d, (40, 220), "报告与决策从模板切换到大模型增强", f_s, MUTED)
|
|
img.save(OUT / "llm_sync.png", quality=95)
|
|
print("llm_sync.png")
|
|
|
|
|
|
def decision_flow():
|
|
w, h = 1300, 420
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_n = font(17, True)
|
|
f_s = font(14)
|
|
text_left(d, (30, 16), "电子病历辅助决策流程", font(22, True), NAVY)
|
|
nodes = [
|
|
("保存病历", TEAL),
|
|
("DecisionSupport\nService", NAVY),
|
|
("FastAPI\n/report/decision", ACCENT),
|
|
("RAG + LLM\n或模板回退", PURPLE),
|
|
("七类建议\nDialog 展示", GREEN),
|
|
]
|
|
for i, (t, c) in enumerate(nodes):
|
|
x = 30 + i * 255
|
|
round_rect(d, (x, 90, x + 220, 200), 14, c)
|
|
for j, ln in enumerate(t.split("\n")):
|
|
text_center(d, (x + 110, 140 + j * 28), ln, f_n, WHITE)
|
|
if i < 4:
|
|
d.line([(x + 230, 155), (x + 245, 155)], fill=NAVY, width=3)
|
|
d.polygon([(x + 250, 155), (x + 238, 147), (x + 238, 163)], fill=NAVY)
|
|
outs = ["治疗", "用药", "护理", "随访", "风险", "冲突", "RAG来源"]
|
|
for i, o in enumerate(outs):
|
|
x = 40 + i * 175
|
|
round_rect(d, (x, 320, x + 155, 380), 10, SOFT, ACCENT, 2)
|
|
text_center(d, (x + 77, 350), o, f_s, NAVY)
|
|
img.save(OUT / "decision_flow.png", quality=95)
|
|
print("decision_flow.png")
|
|
|
|
|
|
def transform_arrow():
|
|
w, h = 900, 360
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_n = font(18, True)
|
|
f_s = font(14)
|
|
text_left(d, (30, 16), "架构演进:从单体到三端分离", font(22, True), NAVY)
|
|
round_rect(d, (40, 80, 360, 300), 16, WHITE, MUTED, 2)
|
|
round_rect(d, (40, 80, 360, 130), 16, MUTED)
|
|
d.rectangle((40, 115, 360, 130), fill=MUTED)
|
|
text_center(d, (200, 105), "早期原型", f_n, WHITE)
|
|
for i, t in enumerate(["Thymeleaf 服务端渲染", "HttpSession 登录", "规则/随机 AI 模拟", "强依赖 MySQL"]):
|
|
text_left(d, (60, 150 + i * 32), "· " + t, f_s, DARK)
|
|
|
|
# arrow
|
|
d.polygon([(400, 180), (480, 160), (480, 175), (560, 175), (560, 185), (480, 185), (480, 200)], fill=ACCENT)
|
|
|
|
round_rect(d, (580, 80, 860, 300), 16, WHITE, ACCENT, 2)
|
|
round_rect(d, (580, 80, 860, 130), 16, ACCENT)
|
|
d.rectangle((580, 115, 860, 130), fill=ACCENT)
|
|
text_center(d, (720, 105), "当前实现", f_n, WHITE)
|
|
for i, t in enumerate(["Vue3 + Element Plus", "Spring Security + JWT", "YOLO 实检 + RAG/LLM", "默认 H2 可离线演示"]):
|
|
text_left(d, (600, 150 + i * 32), "· " + t, f_s, DARK)
|
|
img.save(OUT / "transform.png", quality=95)
|
|
print("transform.png")
|
|
|
|
|
|
def tech_layers():
|
|
w, h = 1400, 520
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_h = font(24, True)
|
|
f_n = font(18, True)
|
|
f_s = font(15)
|
|
text_left(d, (30, 16), "三层技术栈", f_h, NAVY)
|
|
layers = [
|
|
("前端 SPA", TEAL, "Vue 3.5.10 · Vite 5.4.8 · Element Plus 2.8.4 · Pinia · Router · Axios · ECharts · marked"),
|
|
("业务后端", NAVY, "Spring Boot 3.3.4 · Java 17 · Security + jjwt 0.12.6 · JPA · H2/MySQL · Lombok · Maven"),
|
|
("AI 微服务", ACCENT, "FastAPI ≥0.110 · Ultralytics YOLO · OpenCV · LangChain ≥0.2 · DeepSeek 等 OpenAI 兼容"),
|
|
]
|
|
for i, (title, color, desc) in enumerate(layers):
|
|
y = 70 + i * 140
|
|
round_rect(d, (40, y, w - 40, y + 120), 16, WHITE, color, 3)
|
|
round_rect(d, (40, y, 220, y + 120), 16, color)
|
|
d.rectangle((180, y, 220, y + 120), fill=color)
|
|
text_center(d, (130, y + 60), title, f_n, WHITE)
|
|
# wrap desc roughly
|
|
text_left(d, (250, y + 45), desc, f_s, DARK)
|
|
img.save(OUT / "tech_layers.png", quality=95)
|
|
print("tech_layers.png")
|
|
|
|
|
|
def scene_cards():
|
|
"""Four scene cards as one image for roles page."""
|
|
w, h = 1400, 360
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_n = font(20, True)
|
|
f_s = font(15)
|
|
cards = [
|
|
("影像科", "上传胸片 → 一键 AI 诊断\n查看标注框、置信度与分段报告", TEAL),
|
|
("临床医生", "书写病历关键词 → 获取\n治疗/护理/随访建议与知识来源", ACCENT),
|
|
("管理员", "配置 LLM、管理知识库\n切换/上传 YOLO 权重与用户", NAVY),
|
|
("挂号窗口", "预约登记与状态流转\n预约→确认→完成/取消/未到诊", ORANGE),
|
|
]
|
|
for i, (t, desc, c) in enumerate(cards):
|
|
x = 20 + i * 345
|
|
round_rect(d, (x, 30, x + 320, 320), 16, WHITE, c, 3)
|
|
round_rect(d, (x, 30, x + 320, 90), 16, c)
|
|
d.rectangle((x, 70, x + 320, 90), fill=c)
|
|
text_center(d, (x + 160, 60), t, f_n, WHITE)
|
|
# icon circle
|
|
d.ellipse((x + 130, 110, x + 190, 170), fill=SOFT, outline=c, width=3)
|
|
text_center(d, (x + 160, 140), str(i + 1), f_n, c)
|
|
for j, ln in enumerate(desc.split("\n")):
|
|
text_center(d, (x + 160, 200 + j * 28), ln, f_s, DARK)
|
|
img.save(OUT / "scene_cards.png", quality=95)
|
|
print("scene_cards.png")
|
|
|
|
|
|
def yolo_concept():
|
|
"""Simple chest-xray style + detection boxes concept."""
|
|
w, h = 900, 560
|
|
img = Image.new("RGB", (w, h), (20, 28, 40))
|
|
d = ImageDraw.Draw(img)
|
|
f_n = font(18, True)
|
|
f_s = font(14)
|
|
# left original
|
|
round_rect(d, (40, 60, 420, 500), 12, (35, 45, 60))
|
|
text_center(d, (230, 40), "原始影像(示意)", f_n, ACCENT)
|
|
# fake lung-ish shapes
|
|
d.ellipse((90, 140, 220, 380), outline=(180, 200, 210), width=2)
|
|
d.ellipse((240, 140, 370, 380), outline=(180, 200, 210), width=2)
|
|
d.line([(230, 120), (230, 420)], fill=(120, 140, 150), width=2)
|
|
d.rectangle((150, 200, 190, 250), outline=(100, 120, 130), width=1)
|
|
|
|
# right annotated
|
|
round_rect(d, (480, 60, 860, 500), 12, (35, 45, 60))
|
|
text_center(d, (670, 40), "YOLO 标注结果(示意)", f_n, ACCENT)
|
|
d.ellipse((530, 140, 660, 380), outline=(180, 200, 210), width=2)
|
|
d.ellipse((680, 140, 810, 380), outline=(180, 200, 210), width=2)
|
|
d.line([(670, 120), (670, 420)], fill=(120, 140, 150), width=2)
|
|
# detection boxes
|
|
d.rectangle((560, 200, 640, 270), outline=ACCENT, width=3)
|
|
text_left(d, (560, 175), "findings 0.87", f_s, ACCENT)
|
|
d.rectangle((720, 250, 790, 320), outline=ORANGE, width=3)
|
|
text_left(d, (700, 225), "nodule 0.76", f_s, ORANGE)
|
|
text_left(d, (40, 520), "仅教学演示概念图,非真实患者数据与临床结论", f_s, MUTED)
|
|
img.save(OUT / "yolo_concept.png", quality=95)
|
|
print("yolo_concept.png")
|
|
|
|
|
|
def module_tree():
|
|
w, h = 1200, 700
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_h = font(24, True)
|
|
f_n = font(16, True)
|
|
f_s = font(14)
|
|
text_left(d, (30, 20), "仓库三工程结构", f_h, NAVY)
|
|
roots = [
|
|
(40, "frontend/", TEAL, ["src/api HTTP 封装", "src/views 业务页面", "src/router 守卫", "src/stores Pinia"]),
|
|
(420, "smart-hospital/", NAVY, ["controller/service", "security JWT", "uploads/ 影像头像", "data/ai/ 配置与聊天"]),
|
|
(800, "ai-service/", ACCENT, ["api/ imaging·rag", "services/ yolo·llm", "knowledge/ MD", "data/weights/ pt"]),
|
|
]
|
|
for x, title, color, items in roots:
|
|
round_rect(d, (x, 80, x + 340, 640), 16, WHITE, color, 3)
|
|
round_rect(d, (x, 80, x + 340, 150), 16, color)
|
|
d.rectangle((x, 130, x + 340, 150), fill=color)
|
|
text_center(d, (x + 170, 115), title, f_n, WHITE)
|
|
yy = 180
|
|
for it in items:
|
|
round_rect(d, (x + 24, yy, x + 316, yy + 70), 10, SOFT)
|
|
text_left(d, (x + 44, yy + 22), it, f_s, DARK)
|
|
yy += 90
|
|
img.save(OUT / "module_tree.png", quality=95)
|
|
print("module_tree.png")
|
|
|
|
|
|
def closing_bg():
|
|
w, h = 1920, 1080
|
|
img = gradient_bg(w, h, NAVY, (12, 80, 100))
|
|
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
d = ImageDraw.Draw(overlay)
|
|
d.ellipse((760, 280, 1160, 680), outline=(*ACCENT, 100), width=4)
|
|
d.ellipse((820, 340, 1100, 620), outline=(*TEAL, 80), width=3)
|
|
# cross
|
|
cx, cy = 960, 480
|
|
d.rounded_rectangle((cx - 16, cy - 50, cx + 16, cy + 50), radius=6, fill=(*ACCENT, 200))
|
|
d.rounded_rectangle((cx - 50, cy - 16, cx + 50, cy + 16), radius=6, fill=(*ACCENT, 200))
|
|
img = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
|
|
img.save(OUT / "closing_bg.png", quality=95)
|
|
print("closing_bg.png")
|
|
|
|
|
|
def demo_timeline():
|
|
w, h = 1500, 280
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_n = font(14, True)
|
|
f_s = font(12)
|
|
text_left(d, (20, 12), "推荐演示剧本(7 步)", font(20, True), NAVY)
|
|
steps = [
|
|
"doctor1\n登录仪表盘",
|
|
"患者\n360°档案",
|
|
"影像\nAI诊断",
|
|
"病历\n辅助决策",
|
|
"预约\n状态流转",
|
|
"admin\nAI/YOLO",
|
|
"关闭AI\n验证降级",
|
|
]
|
|
n = len(steps)
|
|
for i, t in enumerate(steps):
|
|
x = 40 + i * 210
|
|
d.ellipse((x + 60, 60, x + 110, 110), fill=TEAL if i < 5 else NAVY)
|
|
text_center(d, (x + 85, 85), str(i + 1), font(18, True), WHITE)
|
|
for j, ln in enumerate(t.split("\n")):
|
|
text_center(d, (x + 85, 130 + j * 22), ln, f_s, DARK)
|
|
if i < n - 1:
|
|
d.line([(x + 120, 85), (x + 200, 85)], fill=ACCENT, width=3)
|
|
img.save(OUT / "demo_timeline.png", quality=95)
|
|
print("demo_timeline.png")
|
|
|
|
|
|
def priority_board():
|
|
w, h = 1500, 780
|
|
img = Image.new("RGB", (w, h), LIGHT)
|
|
d = ImageDraw.Draw(img)
|
|
f_h = font(26, True)
|
|
f_n = font(18, True)
|
|
f_s = font(14)
|
|
text_left(d, (30, 16), "功能需求优先级看板", f_h, NAVY)
|
|
cols = [
|
|
(
|
|
"P0 必须具备",
|
|
RED,
|
|
[
|
|
"F-01 登录/登出/改密 · JWT+BCrypt",
|
|
"F-02 患者 CRUD + 360°档案",
|
|
"F-03 影像 CRUD + 上传",
|
|
"F-04 AI 影像诊断状态机",
|
|
"F-05 病历 + 辅助决策",
|
|
"F-06 角色权限双端控制",
|
|
],
|
|
),
|
|
(
|
|
"P1 重要增强",
|
|
ORANGE,
|
|
[
|
|
"F-07 预约挂号全流程",
|
|
"F-08 仪表盘可视化",
|
|
"F-09 AI 对话助手",
|
|
"F-10 知识库管理 RAG",
|
|
"F-11 AI/LLM 配置",
|
|
"F-12 YOLO 权重管理",
|
|
],
|
|
),
|
|
(
|
|
"P2 体验与健壮",
|
|
GREEN,
|
|
[
|
|
"F-13 AI 不可用业务降级",
|
|
"F-14 弹窗 append-to-body",
|
|
"F-15 报告分段可读",
|
|
"F-16 路由过渡与仪表盘体验",
|
|
],
|
|
),
|
|
]
|
|
for i, (title, color, items) in enumerate(cols):
|
|
x = 30 + i * 490
|
|
round_rect(d, (x, 70, x + 460, h - 40), 16, WHITE, color, 3)
|
|
round_rect(d, (x, 70, x + 460, 130), 16, color)
|
|
d.rectangle((x, 110, x + 460, 130), fill=color)
|
|
text_center(d, (x + 230, 100), title, f_n, WHITE)
|
|
yy = 160
|
|
for it in items:
|
|
round_rect(d, (x + 20, yy, x + 440, yy + 70), 10, SOFT)
|
|
text_left(d, (x + 36, yy + 22), it, f_s, DARK)
|
|
yy += 85
|
|
img.save(OUT / "priority_board.png", quality=95)
|
|
print("priority_board.png")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cover_bg()
|
|
dual_chain()
|
|
feature_panorama()
|
|
architecture()
|
|
imaging_flow()
|
|
state_machine()
|
|
er_diagram()
|
|
llm_sync()
|
|
decision_flow()
|
|
transform_arrow()
|
|
tech_layers()
|
|
scene_cards()
|
|
yolo_concept()
|
|
module_tree()
|
|
closing_bg()
|
|
demo_timeline()
|
|
priority_board()
|
|
print("ALL ASSETS DONE ->", OUT)
|