26-7-31-1
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,801 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Build Smart Hospital defense PPT (19 slides) — layout-safe revision."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image as PILImage
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.oxml import parse_xml
|
||||
from pptx.oxml.ns import qn
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
ASSETS = ROOT / "assets"
|
||||
OUT = ROOT / "智慧医院_AI影像诊断与电子病历辅助决策系统_答辩PPT.pptx"
|
||||
OUT_ALT = ROOT / "智慧医院_答辩PPT_优化版.pptx"
|
||||
|
||||
SLIDE_W = Inches(13.333)
|
||||
SLIDE_H = Inches(7.5)
|
||||
|
||||
# Safe content band (below header, above footer)
|
||||
HEADER_H = 0.92
|
||||
CONTENT_TOP = 1.05
|
||||
FOOTER_TOP = 7.12
|
||||
CONTENT_BOTTOM = 7.05
|
||||
CONTENT_H = CONTENT_BOTTOM - CONTENT_TOP # ~6.0"
|
||||
|
||||
NAVY = RGBColor(0x0B, 0x3A, 0x5C)
|
||||
TEAL = RGBColor(0x1A, 0x7A, 0x9C)
|
||||
ACCENT = RGBColor(0x2B, 0xBB, 0xAD)
|
||||
LIGHT = RGBColor(0xF4, 0xF8, 0xFB)
|
||||
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
DARK = RGBColor(0x1E, 0x29, 0x3B)
|
||||
MUTED = RGBColor(0x64, 0x74, 0x8B)
|
||||
SOFT = RGBColor(0xE2, 0xEC, 0xF4)
|
||||
ORANGE = RGBColor(0xF5, 0x9E, 0x0B)
|
||||
GREEN = RGBColor(0x22, 0xC5, 0x5E)
|
||||
RED = RGBColor(0xEF, 0x44, 0x44)
|
||||
CHIP = RGBColor(0x12, 0x4A, 0x6A)
|
||||
PANEL = RGBColor(0x0B, 0x2A, 0x45)
|
||||
|
||||
FONT = "微软雅黑"
|
||||
TOTAL = 19
|
||||
|
||||
|
||||
def set_run_font(run, size=14, bold=False, color=DARK, name=FONT):
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = color
|
||||
run.font.name = name
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
ea = rPr.find(qn("a:ea"))
|
||||
if ea is None:
|
||||
ea = parse_xml(
|
||||
f'<a:ea xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" typeface="{name}"/>'
|
||||
)
|
||||
rPr.append(ea)
|
||||
else:
|
||||
ea.set("typeface", name)
|
||||
|
||||
|
||||
def add_textbox(
|
||||
slide,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
text,
|
||||
size=14,
|
||||
bold=False,
|
||||
color=DARK,
|
||||
align=PP_ALIGN.LEFT,
|
||||
anchor=MSO_ANCHOR.TOP,
|
||||
):
|
||||
box = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.auto_size = None
|
||||
try:
|
||||
tf.paragraphs[0].space_before = Pt(0)
|
||||
tf.paragraphs[0].space_after = Pt(0)
|
||||
except Exception:
|
||||
pass
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = align
|
||||
run = p.add_run()
|
||||
run.text = text
|
||||
set_run_font(run, size=size, bold=bold, color=color)
|
||||
return box
|
||||
|
||||
|
||||
def add_paras(slide, left, top, width, height, lines, size=12, color=DARK, spacing=4, bold=False):
|
||||
box = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
for i, line in enumerate(lines):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(spacing)
|
||||
run = p.add_run()
|
||||
run.text = line
|
||||
set_run_font(run, size=size, bold=bold, color=color)
|
||||
return box
|
||||
|
||||
|
||||
def add_shape(slide, left, top, width, height, fill=NAVY, line=None):
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = fill
|
||||
if line is None:
|
||||
shape.line.fill.background()
|
||||
else:
|
||||
shape.line.color.rgb = line
|
||||
shape.line.width = Pt(1)
|
||||
try:
|
||||
shape.adjustments[0] = 0.08
|
||||
except Exception:
|
||||
pass
|
||||
return shape
|
||||
|
||||
|
||||
def fill_shape_text(shape, lines, size=13, bold=False, color=WHITE, align=PP_ALIGN.CENTER, v_anchor=MSO_ANCHOR.MIDDLE):
|
||||
"""Put multi-line text inside a shape (avoids separate overlapping textboxes)."""
|
||||
tf = shape.text_frame
|
||||
tf.clear()
|
||||
tf.word_wrap = True
|
||||
try:
|
||||
tf.auto_size = None
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(lines, str):
|
||||
lines = lines.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = align
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(2)
|
||||
run = p.add_run()
|
||||
run.text = line
|
||||
set_run_font(run, size=size, bold=bold if i == 0 else bold, color=color)
|
||||
return shape
|
||||
|
||||
|
||||
def add_table(slide, left, top, width, height, data, col_widths=None, font_size=11):
|
||||
rows, cols = len(data), len(data[0])
|
||||
table_shape = slide.shapes.add_table(rows, cols, left, top, width, height)
|
||||
table = table_shape.table
|
||||
if col_widths:
|
||||
for i, w in enumerate(col_widths):
|
||||
table.columns[i].width = w
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
cell = table.cell(r, c)
|
||||
cell.text = ""
|
||||
tf = cell.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(0)
|
||||
run = p.add_run()
|
||||
run.text = str(data[r][c])
|
||||
is_header = r == 0
|
||||
set_run_font(run, size=font_size if not is_header else font_size + 1, bold=is_header, color=WHITE if is_header else DARK)
|
||||
cell.fill.solid()
|
||||
cell.fill.fore_color.rgb = NAVY if is_header else (SOFT if r % 2 == 0 else WHITE)
|
||||
return table_shape
|
||||
|
||||
|
||||
def set_slide_bg(slide, color=LIGHT):
|
||||
fill = slide.background.fill
|
||||
fill.solid()
|
||||
fill.fore_color.rgb = color
|
||||
|
||||
|
||||
def header_bar(slide, title, subtitle=None):
|
||||
"""Single text frame for title+subtitle to avoid stacked textbox overlap."""
|
||||
add_shape(slide, Inches(0), Inches(0), SLIDE_W, Inches(HEADER_H), NAVY)
|
||||
add_shape(slide, Inches(0), Inches(HEADER_H), SLIDE_W, Inches(0.05), ACCENT)
|
||||
box = slide.shapes.add_textbox(Inches(0.4), Inches(0.12), Inches(12.5), Inches(0.72))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(2)
|
||||
run = p.add_run()
|
||||
run.text = title
|
||||
set_run_font(run, size=22, bold=True, color=WHITE)
|
||||
if subtitle:
|
||||
p2 = tf.add_paragraph()
|
||||
p2.alignment = PP_ALIGN.LEFT
|
||||
p2.space_before = Pt(0)
|
||||
p2.space_after = Pt(0)
|
||||
run2 = p2.add_run()
|
||||
run2.text = subtitle
|
||||
set_run_font(run2, size=11, bold=False, color=RGBColor(0xB8, 0xD4, 0xE8))
|
||||
|
||||
|
||||
def footer(slide, page):
|
||||
add_shape(slide, Inches(0), Inches(FOOTER_TOP), SLIDE_W, Inches(7.5 - FOOTER_TOP), NAVY)
|
||||
box = slide.shapes.add_textbox(Inches(0.3), Inches(FOOTER_TOP + 0.04), Inches(10.5), Inches(0.28))
|
||||
tf = box.text_frame
|
||||
p = tf.paragraphs[0]
|
||||
run = p.add_run()
|
||||
run.text = "智慧医院 AI 影像诊断与电子病历辅助决策系统 · 实训答辩 · 仅供教学演示"
|
||||
set_run_font(run, size=10, color=RGBColor(0xC5, 0xD8, 0xE8))
|
||||
box2 = slide.shapes.add_textbox(Inches(11.3), Inches(FOOTER_TOP + 0.04), Inches(1.7), Inches(0.28))
|
||||
p2 = box2.text_frame.paragraphs[0]
|
||||
p2.alignment = PP_ALIGN.RIGHT
|
||||
run2 = p2.add_run()
|
||||
run2.text = f"{page} / {TOTAL}"
|
||||
set_run_font(run2, size=10, bold=True, color=WHITE)
|
||||
|
||||
|
||||
def fit_image(slide, path, left, top, max_width, max_height):
|
||||
"""Place image scaled to fit inside max box; never exceed max_height (prevents footer crash)."""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return None
|
||||
with PILImage.open(path) as im:
|
||||
iw, ih = im.size
|
||||
aspect = iw / ih
|
||||
# convert max to inches floats
|
||||
mw = max_width if isinstance(max_width, float) else max_width / 914400
|
||||
mh = max_height if isinstance(max_height, float) else max_height / 914400
|
||||
# left/top may be Inches
|
||||
def to_in(v):
|
||||
return v if isinstance(v, float) else v / 914400
|
||||
|
||||
l = to_in(left)
|
||||
t = to_in(top)
|
||||
w = mw
|
||||
h = w / aspect
|
||||
if h > mh:
|
||||
h = mh
|
||||
w = h * aspect
|
||||
return slide.shapes.add_picture(str(path), Inches(l), Inches(t), width=Inches(w), height=Inches(h))
|
||||
|
||||
|
||||
def blank_slide(prs):
|
||||
return prs.slides.add_slide(prs.slide_layouts[6])
|
||||
|
||||
|
||||
def card(slide, left, top, width, height, title, body_lines, color=TEAL, title_size=14, body_size=12):
|
||||
"""Card with colored title band + body text, all non-overlapping zones."""
|
||||
add_shape(slide, Inches(left), Inches(top), Inches(width), Inches(height), WHITE, SOFT)
|
||||
title_h = 0.48
|
||||
band = add_shape(slide, Inches(left), Inches(top), Inches(width), Inches(title_h), color)
|
||||
fill_shape_text(band, [title], size=title_size, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
# body region strictly below band
|
||||
body_top = top + title_h + 0.12
|
||||
body_h = height - title_h - 0.2
|
||||
if body_lines:
|
||||
add_paras(
|
||||
slide,
|
||||
Inches(left + 0.15),
|
||||
Inches(body_top),
|
||||
Inches(width - 0.3),
|
||||
Inches(body_h),
|
||||
body_lines,
|
||||
size=body_size,
|
||||
color=DARK,
|
||||
spacing=5,
|
||||
)
|
||||
|
||||
|
||||
# ---------------- slides ----------------
|
||||
|
||||
def slide_01_cover(prs):
|
||||
slide = blank_slide(prs)
|
||||
fit_image(slide, ASSETS / "cover_bg.png", 0, 0, 13.333, 7.5)
|
||||
|
||||
# Center panel — leave room for disclaimer
|
||||
add_shape(slide, Inches(1.1), Inches(1.35), Inches(11.1), Inches(4.35), PANEL)
|
||||
add_textbox(slide, Inches(1.4), Inches(1.55), Inches(10.5), Inches(0.35),
|
||||
"SMART HOSPITAL · DEFENSE PRESENTATION", size=13, color=ACCENT, align=PP_ALIGN.CENTER)
|
||||
add_textbox(slide, Inches(1.4), Inches(2.05), Inches(10.5), Inches(1.1),
|
||||
"智慧医院 AI 影像诊断与\n电子病历辅助决策系统", size=32, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
add_textbox(slide, Inches(1.4), Inches(3.25), Inches(10.5), Inches(0.4),
|
||||
"实训教学演示级 Web 应用 · 业务系统 + AI 微服务混合架构",
|
||||
size=15, color=RGBColor(0xB8, 0xD4, 0xE8), align=PP_ALIGN.CENTER)
|
||||
|
||||
chips = [("文档版本", "v1.0"), ("日期", "2026-07-27"), ("前端", "Vue3 :5173"),
|
||||
("业务端", "Boot :8080"), ("AI", "FastAPI :8001")]
|
||||
for i, (k, v) in enumerate(chips):
|
||||
x = 1.5 + i * 2.05
|
||||
chip = add_shape(slide, Inches(x), Inches(4.0), Inches(1.9), Inches(0.95), CHIP)
|
||||
fill_shape_text(chip, [k, v], size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
# make first line accent-ish by rewriting as single centered block is OK
|
||||
|
||||
add_textbox(slide, Inches(1.2), Inches(6.2), Inches(10.9), Inches(0.7),
|
||||
"声明:本系统输出仅供教学实训与辅助决策演示,不能替代执业医师的正式诊断与医疗文书。",
|
||||
size=12, color=RGBColor(0xE2, 0xEC, 0xF4), align=PP_ALIGN.CENTER)
|
||||
|
||||
|
||||
def slide_02_toc(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "目录导览", "课程/实训答辩叙事结构")
|
||||
sections = [
|
||||
("01", "主题与需求", "P3 – P6", "主题定位 · 场景角色 · 痛点改造 · 需求优先级", TEAL),
|
||||
("02", "功能与业务", "P7 – P11", "功能全景 · 核心模块 · 影像诊断 · 病历决策 · 管理端", ACCENT),
|
||||
("03", "架构与技术", "P12 – P16", "技术栈 · 逻辑架构 · 工程结构 · 核心流程 · 数据与安全", NAVY),
|
||||
("04", "部署与总结", "P17 – P19", "运行部署 · 演示路径 · 设计取舍 · 边界与展望", ORANGE),
|
||||
]
|
||||
# 4 rows fit in 1.15 ~ 6.9
|
||||
row_h = 1.25
|
||||
gap = 0.12
|
||||
start_y = 1.15
|
||||
for i, (num, title, pages, desc, color) in enumerate(sections):
|
||||
y = start_y + i * (row_h + gap)
|
||||
add_shape(slide, Inches(0.55), Inches(y), Inches(12.2), Inches(row_h), WHITE, SOFT)
|
||||
num_box = add_shape(slide, Inches(0.55), Inches(y), Inches(1.25), Inches(row_h), color)
|
||||
fill_shape_text(num_box, [num], size=26, bold=True, color=WHITE)
|
||||
add_textbox(slide, Inches(2.05), Inches(y + 0.22), Inches(7.2), Inches(0.4),
|
||||
title, size=20, bold=True, color=NAVY)
|
||||
add_textbox(slide, Inches(2.05), Inches(y + 0.7), Inches(7.5), Inches(0.35),
|
||||
desc, size=12, color=MUTED)
|
||||
page_box = add_shape(slide, Inches(10.4), Inches(y + 0.35), Inches(1.9), Inches(0.55), color)
|
||||
fill_shape_text(page_box, [pages], size=13, bold=True, color=WHITE)
|
||||
footer(slide, 2)
|
||||
|
||||
|
||||
def slide_03_theme(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "项目主题与建设目标", "围绕医院诊疗两条核心链路展开")
|
||||
|
||||
# Left image max height keeps clear of footer
|
||||
fit_image(slide, ASSETS / "dual_chain.png", 0.3, 1.12, 8.0, 5.7)
|
||||
|
||||
# Right goals — fixed band, no overflow
|
||||
gx, gy, gw, gh = 8.5, 1.12, 4.4, 5.7
|
||||
add_shape(slide, Inches(gx), Inches(gy), Inches(gw), Inches(gh), WHITE, SOFT)
|
||||
band = add_shape(slide, Inches(gx), Inches(gy), Inches(gw), Inches(0.45), NAVY)
|
||||
fill_shape_text(band, ["建设目标五维"], size=14, bold=True, color=WHITE)
|
||||
|
||||
goals = [
|
||||
("业务闭环", "登录、患者、影像、病历、预约、用户管理"),
|
||||
("AI 可演示", "YOLO 实检 + DeepSeek 兼容 / 模板降级"),
|
||||
("前后端分离", "Vue3 SPA + Spring Boot + FastAPI"),
|
||||
("可离线实训", "默认 H2,AI 宕机业务仍可演示"),
|
||||
("安全可讲", "JWT · 角色权限 · BCrypt · 双端守卫"),
|
||||
]
|
||||
item_h = 0.95
|
||||
for i, (t, d) in enumerate(goals):
|
||||
y = gy + 0.55 + i * item_h
|
||||
item = add_shape(slide, Inches(gx + 0.15), Inches(y), Inches(gw - 0.3), Inches(0.85), SOFT)
|
||||
fill_shape_text(item, [t, d], size=12, bold=False, color=DARK, align=PP_ALIGN.LEFT)
|
||||
footer(slide, 3)
|
||||
|
||||
|
||||
def slide_04_scenes(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "应用场景与用户角色", "教学/演示四类场景 + 三角色权限模型")
|
||||
|
||||
# Image top region
|
||||
fit_image(slide, ASSETS / "scene_cards.png", 0.4, 1.1, 12.5, 2.7)
|
||||
|
||||
data = [
|
||||
["角色", "代码枚举", "核心诉求"],
|
||||
["系统管理员", "ADMIN", "用户管理、AI 大模型配置、YOLO 权重、全局运维"],
|
||||
["临床医生", "DOCTOR", "患者与病历、辅助决策、预约、查看影像报告"],
|
||||
["影像医师", "RADIOLOGIST", "影像登记、上传、触发 AI 诊断、审阅标注与报告"],
|
||||
["访客/未登录", "—", "仅可访问登录页"],
|
||||
]
|
||||
add_table(slide, Inches(0.4), Inches(4.0), Inches(12.5), Inches(2.85), data,
|
||||
col_widths=[Inches(2.2), Inches(2.4), Inches(7.9)], font_size=12)
|
||||
footer(slide, 4)
|
||||
|
||||
|
||||
def slide_05_pain(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "需求分析:痛点与改造", "从教学型 CRUD 到可演示的 AI 混合架构")
|
||||
|
||||
data = [
|
||||
["问题", "传统表现", "本项目对策"],
|
||||
["业务割裂", "缺统一档案视图", "患者 360° 档案关联全业务"],
|
||||
["AI 仅假数据", "随机文案/关键字", "YOLO 实检 + LLM/RAG,可降级"],
|
||||
["技术栈陈旧", "Thymeleaf 耦合", "Vue3 SPA + REST 分离"],
|
||||
["权限薄弱", "仅 Session 登录", "JWT + 角色 + 双端守卫"],
|
||||
["不可降级", "依赖失败即中断", "Spring 规则/模板兜底"],
|
||||
]
|
||||
add_table(slide, Inches(0.35), Inches(1.15), Inches(7.0), Inches(3.55), data,
|
||||
col_widths=[Inches(1.5), Inches(2.5), Inches(3.0)], font_size=11)
|
||||
|
||||
fit_image(slide, ASSETS / "transform.png", 7.55, 1.15, 5.4, 3.55)
|
||||
|
||||
nfr = add_shape(slide, Inches(0.35), Inches(4.9), Inches(12.6), Inches(1.95), WHITE, TEAL)
|
||||
fill_shape_text(
|
||||
nfr,
|
||||
[
|
||||
"非功能需求落地",
|
||||
"性能:@Async 异步诊断 + 有限轮询 | 可用性:默认 H2 + 种子数据 | 安全:JWT / BCrypt / CORS",
|
||||
"可维护:Result<T> + 全局异常 | 可扩展:AI 独立进程 | 兼容:OpenAI 协议(DeepSeek / Qwen)",
|
||||
],
|
||||
size=12,
|
||||
color=DARK,
|
||||
align=PP_ALIGN.LEFT,
|
||||
)
|
||||
footer(slide, 5)
|
||||
|
||||
|
||||
def slide_06_priority(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "功能需求优先级", "P0 必须 · P1 增强 · P2 体验(文档 2.3)")
|
||||
fit_image(slide, ASSETS / "priority_board.png", 0.35, 1.1, 12.6, 5.8)
|
||||
footer(slide, 6)
|
||||
|
||||
|
||||
def slide_07_panorama(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "功能全景", "身份权限 · 主数据 · 诊疗业务 · 智能能力")
|
||||
fit_image(slide, ASSETS / "feature_panorama.png", 0.3, 1.1, 8.7, 5.8)
|
||||
|
||||
routes = [
|
||||
"/login 登录",
|
||||
"/dashboard 仪表盘",
|
||||
"/patients 患者",
|
||||
"/imaging 影像诊断",
|
||||
"/emrs 电子病历",
|
||||
"/appointments 预约",
|
||||
"/ai-assistant 助手",
|
||||
"/ai-knowledge 知识库",
|
||||
"/ai-settings ADMIN",
|
||||
"/ai-yolo ADMIN",
|
||||
"/users ADMIN",
|
||||
]
|
||||
card(slide, 9.2, 1.1, 3.7, 5.8, "前端路由一览", ["• " + r for r in routes], TEAL, body_size=12)
|
||||
footer(slide, 7)
|
||||
|
||||
|
||||
def slide_08_modules(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "核心业务模块", "仪表盘 · 患者 360° · 预约状态机")
|
||||
cards = [
|
||||
("仪表盘 Dashboard", TEAL, [
|
||||
"• KPI:患者 / 影像 / 病历 / 预约及状态分布",
|
||||
"• 近 7 日业务趋势(ECharts)",
|
||||
"• 检查类型、诊断状态分布饼图",
|
||||
"• 快捷入口与待办,便于演示导览",
|
||||
]),
|
||||
("患者管理 + 360° 档案", ACCENT, [
|
||||
"• 分页列表、姓名等关键字搜索",
|
||||
"• 证件、电话、地址、既往史",
|
||||
"• 抽屉关联:影像 / 病历 / 预约",
|
||||
"• 表单等宽两列,弹窗体验优化",
|
||||
]),
|
||||
("预约挂号", ORANGE, [
|
||||
"• 新建 / 编辑 / 删除预约",
|
||||
"• 状态:预约 → 确认 → 完成",
|
||||
"• 亦可:取消 / 未到诊",
|
||||
"• 按日筛选、统计卡片、详情抽屉",
|
||||
]),
|
||||
]
|
||||
for i, (title, color, items) in enumerate(cards):
|
||||
card(slide, 0.35 + i * 4.3, 1.15, 4.1, 5.7, title, items, color, title_size=15, body_size=13)
|
||||
footer(slide, 8)
|
||||
|
||||
|
||||
def slide_09_imaging(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "影像诊断模块(深度)", "检查登记 · 状态机 · YOLO 结果展示 · 可降级")
|
||||
|
||||
# Row 1: capability + state machine
|
||||
card(slide, 0.3, 1.1, 4.0, 2.35, "检查登记能力", [
|
||||
"• 类型:X_RAY / CT / MRI / ULTRASOUND",
|
||||
"• 选择患者、部位、上传或路径",
|
||||
"• 筛选:关键字 / 状态 / 类型",
|
||||
"• 状态 KPI 卡片可快速过滤",
|
||||
], TEAL, body_size=11)
|
||||
|
||||
fit_image(slide, ASSETS / "state_machine.png", 4.5, 1.1, 8.4, 2.35)
|
||||
|
||||
# Row 2: yolo + results — constrained heights
|
||||
fit_image(slide, ASSETS / "yolo_concept.png", 0.3, 3.6, 5.3, 3.3)
|
||||
card(slide, 5.8, 3.6, 7.1, 3.3, "诊断结果展示清单", [
|
||||
"① 诊断印象 + 置信度仪表盘",
|
||||
"② 原始影像 vs YOLO 标注图对比",
|
||||
"③ 影像所见(分段)+ 建议编号列表",
|
||||
"④ 检测明细:类别 / 置信度 / bbox",
|
||||
"⑤ 完整报告:头 / 所见 / 印象 / 建议 / 声明",
|
||||
"⑥ AI 不可用 → Spring 本地规则降级",
|
||||
], ACCENT, body_size=12)
|
||||
footer(slide, 9)
|
||||
|
||||
|
||||
def slide_10_emr(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "电子病历与 AI 辅助决策(深度)", "结构化录入 → RAG/LLM 决策 → 分节 Dialog")
|
||||
|
||||
fit_image(slide, ASSETS / "decision_flow.png", 0.35, 1.1, 12.6, 2.5)
|
||||
|
||||
# Bottom two cards
|
||||
fields = ["主诉", "现病史", "体格检查", "诊断", "治疗方案", "用药", "随访"]
|
||||
add_shape(slide, Inches(0.35), Inches(3.8), Inches(6.2), Inches(3.05), WHITE, SOFT)
|
||||
band = add_shape(slide, Inches(0.35), Inches(3.8), Inches(6.2), Inches(0.45), TEAL)
|
||||
fill_shape_text(band, ["病历字段链路"], size=13, bold=True, color=WHITE)
|
||||
for i, f in enumerate(fields):
|
||||
x = 0.55 + (i % 4) * 1.45
|
||||
y = 4.45 + (i // 4) * 0.95
|
||||
chip = add_shape(slide, Inches(x), Inches(y), Inches(1.35), Inches(0.7), TEAL if i < 4 else ACCENT)
|
||||
fill_shape_text(chip, [f], size=12, bold=True, color=WHITE)
|
||||
|
||||
card(slide, 6.8, 3.8, 6.15, 3.05, "决策输出七块 + 关键词增强", [
|
||||
"• 输出:治疗 / 用药 / 护理 / 随访 / 风险 / 冲突 / RAG 来源",
|
||||
"• 关键词:高血压 · 糖尿病 · 肺炎 · 结节",
|
||||
"• 配置 LLM 后由大模型增强;失败回退模板",
|
||||
"• 保存后可自动弹出;列表可再次查看",
|
||||
], ACCENT, body_size=12)
|
||||
footer(slide, 10)
|
||||
|
||||
|
||||
def slide_11_admin(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "智能运营与管理端", "AI 助手 · 知识库 · LLM 配置 · YOLO 权重 · 用户权限")
|
||||
|
||||
mods = [
|
||||
("AI 助手", ["多轮对话 · Markdown", "marked + DOMPurify", "history → chat-history.json"], TEAL),
|
||||
("知识库", ["文档 CRUD · 启停", "配合 RAG 管线", "内置医学 MD 片段"], ACCENT),
|
||||
("AI 配置", ["仅 ADMIN", "Base URL / Key / Model", "settings.json 持久化"], NAVY),
|
||||
("YOLO 管理", ["仅 ADMIN", "权重列表/激活/上传", "real / demo 模式"], ORANGE),
|
||||
]
|
||||
for i, (t, lines, c) in enumerate(mods):
|
||||
card(slide, 0.3 + i * 3.25, 1.1, 3.1, 2.4, t, ["• " + x for x in lines], c, body_size=11)
|
||||
|
||||
fit_image(slide, ASSETS / "llm_sync.png", 0.3, 3.7, 7.9, 3.15)
|
||||
card(slide, 8.4, 3.7, 4.5, 3.15, "权限双端防护", [
|
||||
"• 前端:路由 meta.roles 隐藏/拦截",
|
||||
"• 后端:@PreAuthorize 接口鉴权",
|
||||
"• 用户 CRUD:角色/科室/启停",
|
||||
"• 禁止停用当前登录账号",
|
||||
"• doctor 访问 /users → 403",
|
||||
], NAVY, body_size=12)
|
||||
footer(slide, 11)
|
||||
|
||||
|
||||
def slide_12_tech(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "技术栈总览", "版本号与文档附录 A 对齐")
|
||||
|
||||
# Image top, table bottom — no overlap
|
||||
fit_image(slide, ASSETS / "tech_layers.png", 0.4, 1.1, 12.5, 2.7)
|
||||
|
||||
data = [
|
||||
["层级", "关键技术", "版本/说明"],
|
||||
["前端", "Vue / Vite / Element Plus / Pinia / ECharts", "3.5.10 / 5.4.8 / 2.8.4 / 2.2.4 / 5.5.1"],
|
||||
["业务端", "Spring Boot / Java / jjwt / JPA", "3.3.4 / 17 / 0.12.6"],
|
||||
["数据库", "H2 默认 · MySQL 可选 profile", "mem:smart_hospital / application-mysql.yml"],
|
||||
["AI 服务", "FastAPI / YOLO / LangChain / LLM", "≥0.110 / Ultralytics / ≥0.2 / DeepSeek 兼容"],
|
||||
["工程", "前端代理 /api→8080;AI base-url 8001", "uploads/ · data/ai/ · data/weights/"],
|
||||
]
|
||||
add_table(slide, Inches(0.4), Inches(4.0), Inches(12.5), Inches(2.9), data,
|
||||
col_widths=[Inches(1.5), Inches(5.6), Inches(5.4)], font_size=11)
|
||||
footer(slide, 12)
|
||||
|
||||
|
||||
def slide_13_arch(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "系统逻辑架构", "浏览器 → 业务端 → 数据库 / AI 微服务")
|
||||
fit_image(slide, ASSETS / "architecture.png", 0.35, 1.1, 12.6, 5.85)
|
||||
footer(slide, 13)
|
||||
|
||||
|
||||
def slide_14_modules_dir(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "工程目录与模块结构", "frontend · smart-hospital · ai-service")
|
||||
fit_image(slide, ASSETS / "module_tree.png", 0.25, 1.1, 8.3, 5.8)
|
||||
card(slide, 8.7, 1.1, 4.2, 5.8, "关键落盘与配置", [
|
||||
"• ./uploads/images 影像文件",
|
||||
"• ./uploads 头像等",
|
||||
"• ./data/ai/settings.json",
|
||||
"• ./data/ai/chat-history.json",
|
||||
"• ai-service/data/weights/",
|
||||
"• ai-service/app/knowledge/",
|
||||
"• jwt.* 过期与前缀",
|
||||
"• cors → localhost:5173",
|
||||
"• ai.service.base-url:8001",
|
||||
"• multipart 最大约 500MB",
|
||||
"• H2 控制台 /h2-console",
|
||||
], NAVY, body_size=12)
|
||||
footer(slide, 14)
|
||||
|
||||
|
||||
def slide_15_flow(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "核心流程:AI 影像诊断", "异步状态机 + FastAPI 检测报告 + 前端轮询")
|
||||
fit_image(slide, ASSETS / "imaging_flow.png", 0.3, 1.1, 12.7, 5.85)
|
||||
footer(slide, 15)
|
||||
|
||||
|
||||
def slide_16_data_api(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "数据模型 · 接口 · 安全", "实体关系、统一响应、REST 与鉴权要点")
|
||||
|
||||
fit_image(slide, ASSETS / "er_diagram.png", 0.25, 1.1, 6.3, 4.0)
|
||||
|
||||
# Right column stacked, no collision
|
||||
card(slide, 6.7, 1.1, 6.2, 1.35, "统一响应 Result<T>", [
|
||||
'{ "code": 0, "message": "OK", "data": {} }',
|
||||
"code != 0 → Axios 拦截器 ElMessage 提示",
|
||||
], TEAL, body_size=11)
|
||||
|
||||
data = [
|
||||
["方法/路径", "说明", "权限"],
|
||||
["POST /api/auth/login", "登录签发 JWT", "公开"],
|
||||
["GET /api/stats/overview", "仪表盘统计", "登录"],
|
||||
["POST .../analyze/{id}", "触发诊断", "登录"],
|
||||
["GET .../ai-suggestions", "辅助决策", "登录"],
|
||||
["* /api/users/**", "用户管理", "ADMIN"],
|
||||
["AI /imaging/analyze 等", "检测/决策/RAG", "经业务端"],
|
||||
]
|
||||
add_table(slide, Inches(6.7), Inches(2.6), Inches(6.2), Inches(2.5), data,
|
||||
col_widths=[Inches(2.7), Inches(2.0), Inches(1.5)], font_size=10)
|
||||
|
||||
card(slide, 0.25, 5.25, 12.65, 1.6, "安全要点", [
|
||||
"JWT 无状态 · BCrypt 密码 · CORS 白名单 · 角色 ADMIN / DOCTOR / RADIOLOGIST",
|
||||
"前端路由守卫 + 后端 @PreAuthorize · 修改密码后需重新登录",
|
||||
], NAVY, body_size=12)
|
||||
footer(slide, 16)
|
||||
|
||||
|
||||
def slide_17_deploy(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "部署运行与演示账号", "启动顺序 · 健康检查 · 内置账号 · 演示剧本")
|
||||
|
||||
env = [
|
||||
["组件", "要求"],
|
||||
["JDK", "17+"],
|
||||
["Node.js", "18+"],
|
||||
["Python", "3.10+(推荐 3.11/3.12)"],
|
||||
["可选", "MySQL 8.x;GPU 非必须"],
|
||||
]
|
||||
add_table(slide, Inches(0.3), Inches(1.1), Inches(3.9), Inches(2.15), env,
|
||||
col_widths=[Inches(1.2), Inches(2.7)], font_size=11)
|
||||
|
||||
health = [
|
||||
["服务", "地址"],
|
||||
["前端", "http://localhost:5173"],
|
||||
["业务 API", "http://localhost:8080/api/..."],
|
||||
["H2 控制台", "http://localhost:8080/h2-console"],
|
||||
["AI 健康", "http://127.0.0.1:8001/health"],
|
||||
["AI 文档", "http://127.0.0.1:8001/docs"],
|
||||
]
|
||||
add_table(slide, Inches(4.4), Inches(1.1), Inches(4.5), Inches(2.15), health,
|
||||
col_widths=[Inches(1.4), Inches(3.1)], font_size=10)
|
||||
|
||||
card(slide, 9.1, 1.1, 3.85, 2.15, "启动顺序", [
|
||||
"1) ai-service :8001",
|
||||
"2) smart-hospital :8080",
|
||||
"3) frontend :5173",
|
||||
"推荐:先 AI → 业务 → 前端",
|
||||
], ACCENT, body_size=11)
|
||||
|
||||
accounts = [
|
||||
["用户名", "密码", "角色", "说明"],
|
||||
["admin", "admin123", "ADMIN", "用户 / AI 配置 / YOLO"],
|
||||
["doctor1", "pass123", "DOCTOR", "临床主演示"],
|
||||
["radio1", "radio123", "RADIOLOGIST", "影像演示"],
|
||||
]
|
||||
add_table(slide, Inches(0.3), Inches(3.45), Inches(12.7), Inches(1.55), accounts,
|
||||
col_widths=[Inches(2.2), Inches(2.5), Inches(2.8), Inches(5.2)], font_size=12)
|
||||
|
||||
fit_image(slide, ASSETS / "demo_timeline.png", 0.3, 5.2, 12.7, 1.7)
|
||||
footer(slide, 17)
|
||||
|
||||
|
||||
def slide_18_design(prs):
|
||||
slide = blank_slide(prs)
|
||||
set_slide_bg(slide)
|
||||
header_bar(slide, "设计取舍 · 边界 · 展望", "答辩可讲清「为什么这样设计」")
|
||||
|
||||
cols = [
|
||||
("关键设计取舍", TEAL, [
|
||||
"• 默认 H2 → 降低实训门槛",
|
||||
"• AI 独立进程 → 隔离 Python 依赖",
|
||||
"• JWT 无状态 → 适配前后端分离",
|
||||
"• LLM 可关 → 无 Key 仍可演示",
|
||||
"• 报告强制分段 → 避免墙文本",
|
||||
"• Dialog append-to-body → 防裁切",
|
||||
]),
|
||||
("已知边界(非缺陷)", ORANGE, [
|
||||
"• 非完整 HIS/PACS/RIS 产品",
|
||||
"• 无医保 / 收费 / CA / 电子签",
|
||||
"• YOLO 为演示级权重与类别",
|
||||
"• H2 重启丢失业务表数据",
|
||||
"• 外网 LLM 受网络/额度影响",
|
||||
"• DICOM 完整工作流非重点",
|
||||
]),
|
||||
("后续可扩展", ACCENT, [
|
||||
"• DICOM 解析与序列阅片",
|
||||
"• 报告 PDF 与医师签收",
|
||||
"• 更细科室权限与审计",
|
||||
"• 向量库增强 RAG",
|
||||
"• Docker Compose 一键拉起",
|
||||
"• 接口自动化测试与 CI",
|
||||
]),
|
||||
]
|
||||
for i, (title, color, items) in enumerate(cols):
|
||||
card(slide, 0.3 + i * 4.3, 1.1, 4.15, 4.35, title, items, color, body_size=12)
|
||||
|
||||
cmp_data = [
|
||||
["维度", "早期", "当前"],
|
||||
["前端", "Thymeleaf + Bootstrap", "Vue3 + Element Plus + ECharts"],
|
||||
["安全", "HttpSession", "Spring Security + JWT"],
|
||||
["AI 影像", "规则/随机模拟", "YOLO 实检 + LLM/模板报告"],
|
||||
]
|
||||
add_table(slide, Inches(0.3), Inches(5.6), Inches(12.7), Inches(1.3), cmp_data,
|
||||
col_widths=[Inches(2.0), Inches(5.2), Inches(5.5)], font_size=11)
|
||||
footer(slide, 18)
|
||||
|
||||
|
||||
def slide_19_end(prs):
|
||||
slide = blank_slide(prs)
|
||||
fit_image(slide, ASSETS / "closing_bg.png", 0, 0, 13.333, 7.5)
|
||||
|
||||
add_textbox(slide, Inches(1), Inches(1.1), Inches(11.3), Inches(0.55),
|
||||
"总结与致谢", size=30, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
add_textbox(slide, Inches(1.2), Inches(1.7), Inches(10.9), Inches(0.45),
|
||||
"智慧医院 = 业务闭环 + 真 AI 链路 + 可降级演示 + 可讲清的工程与安全",
|
||||
size=14, color=ACCENT, align=PP_ALIGN.CENTER)
|
||||
|
||||
values = [
|
||||
("业务闭环", "患者—影像—病历—预约统一"),
|
||||
("真 AI 链路", "YOLO 检测 + RAG/LLM 决策"),
|
||||
("可降级", "无 Key / AI 宕机仍可答辩"),
|
||||
("工程可讲", "三端分离 · JWT · 异步状态机"),
|
||||
]
|
||||
for i, (t, d) in enumerate(values):
|
||||
x = 1.15 + i * 2.85
|
||||
box = add_shape(slide, Inches(x), Inches(2.4), Inches(2.65), Inches(1.45), CHIP)
|
||||
fill_shape_text(box, [t, d], size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
|
||||
add_textbox(slide, Inches(1.0), Inches(4.15), Inches(11.3), Inches(0.55),
|
||||
"验证要点:三端可登录 · 角色 403 正确 · 影像 COMPLETED 报告分段 · 病历建议多类 · 预约可流转 · 改密需重登",
|
||||
size=12, color=RGBColor(0xC5, 0xD8, 0xE8), align=PP_ALIGN.CENTER)
|
||||
|
||||
add_textbox(slide, Inches(1), Inches(4.9), Inches(11.3), Inches(0.55),
|
||||
"谢谢聆听 · 欢迎提问", size=26, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
|
||||
add_textbox(slide, Inches(1.2), Inches(5.7), Inches(10.9), Inches(0.9),
|
||||
"声明:输出内容仅供教学实训与辅助决策演示,不能替代执业医师正式诊断与医疗文书。\n"
|
||||
"文档依据:项目详细文档.md v1.0 · 2026-07-27",
|
||||
size=11, color=RGBColor(0xB8, 0xD4, 0xE8), align=PP_ALIGN.CENTER)
|
||||
|
||||
|
||||
def main():
|
||||
prs = Presentation()
|
||||
prs.slide_width = SLIDE_W
|
||||
prs.slide_height = SLIDE_H
|
||||
|
||||
slide_01_cover(prs)
|
||||
slide_02_toc(prs)
|
||||
slide_03_theme(prs)
|
||||
slide_04_scenes(prs)
|
||||
slide_05_pain(prs)
|
||||
slide_06_priority(prs)
|
||||
slide_07_panorama(prs)
|
||||
slide_08_modules(prs)
|
||||
slide_09_imaging(prs)
|
||||
slide_10_emr(prs)
|
||||
slide_11_admin(prs)
|
||||
slide_12_tech(prs)
|
||||
slide_13_arch(prs)
|
||||
slide_14_modules_dir(prs)
|
||||
slide_15_flow(prs)
|
||||
slide_16_data_api(prs)
|
||||
slide_17_deploy(prs)
|
||||
slide_18_design(prs)
|
||||
slide_19_end(prs)
|
||||
|
||||
try:
|
||||
prs.save(OUT)
|
||||
print(f"Saved: {OUT}")
|
||||
except PermissionError:
|
||||
prs.save(OUT_ALT)
|
||||
print(f"原文件被占用,已另存: {OUT_ALT}")
|
||||
print(f"Slides: {len(prs.slides)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,656 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
智慧医院项目答辩 PPT v2
|
||||
结构:项目主题 → 项目需求 → 技术栈 → 实现功能 → 六人分工
|
||||
要求:图文表并茂、色彩丰富、字体统一、≤20 页、布局防重叠
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image as PILImage
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.oxml import parse_xml
|
||||
from pptx.oxml.ns import qn
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
ASSETS = ROOT / "assets_v2"
|
||||
OUT = ROOT / "智慧医院_项目答辩PPT.pptx"
|
||||
OUT_ALT = ROOT / "智慧医院_项目答辩PPT_v2.pptx"
|
||||
|
||||
SLIDE_W = Inches(13.333)
|
||||
SLIDE_H = Inches(7.5)
|
||||
HEADER_H = 0.88
|
||||
FOOTER_Y = 7.12
|
||||
TOTAL = 18
|
||||
|
||||
# —— 统一字体 ——
|
||||
FONT = "微软雅黑"
|
||||
SIZE_H1 = 22
|
||||
SIZE_H2 = 16
|
||||
SIZE_BODY = 12
|
||||
SIZE_SMALL = 11
|
||||
SIZE_TABLE = 11
|
||||
|
||||
# —— 多彩配色 ——
|
||||
NAVY = RGBColor(0x0F, 0x20, 0x5A)
|
||||
BLUE = RGBColor(0x25, 0x63, 0xEB)
|
||||
CYAN = RGBColor(0x06, 0xB6, 0xD4)
|
||||
TEAL = RGBColor(0x14, 0xB8, 0xA6)
|
||||
PURPLE = RGBColor(0xA8, 0x55, 0xF7)
|
||||
VIOLET = RGBColor(0x8B, 0x5C, 0xF6)
|
||||
ORANGE = RGBColor(0xF9, 0x73, 0x16)
|
||||
CORAL = RGBColor(0xFB, 0x71, 0x85)
|
||||
PINK = RGBColor(0xEC, 0x48, 0x99)
|
||||
GREEN = RGBColor(0x22, 0xC5, 0x5E)
|
||||
INDIGO = RGBColor(0x63, 0x66, 0xF1)
|
||||
YELLOW = RGBColor(0xEA, 0xB3, 0x08)
|
||||
DARK = RGBColor(0x0F, 0x17, 0x2A)
|
||||
MUTED = RGBColor(0x64, 0x74, 0x8B)
|
||||
LIGHT = RGBColor(0xF8, 0xFA, 0xFC)
|
||||
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
SOFT = RGBColor(0xF1, 0xF5, 0xF9)
|
||||
PANEL = RGBColor(0x0C, 0x1A, 0x40)
|
||||
|
||||
SECTION_COLORS = [BLUE, CORAL, PURPLE, TEAL, ORANGE] # 五大篇章色
|
||||
|
||||
|
||||
def set_font(run, size=SIZE_BODY, bold=False, color=DARK):
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = color
|
||||
run.font.name = FONT
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
ea = rPr.find(qn("a:ea"))
|
||||
if ea is None:
|
||||
ea = parse_xml(
|
||||
f'<a:ea xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" typeface="{FONT}"/>'
|
||||
)
|
||||
rPr.append(ea)
|
||||
else:
|
||||
ea.set("typeface", FONT)
|
||||
|
||||
|
||||
def blank(prs):
|
||||
return prs.slides.add_slide(prs.slide_layouts[6])
|
||||
|
||||
|
||||
def bg(slide, color=LIGHT):
|
||||
f = slide.background.fill
|
||||
f.solid()
|
||||
f.fore_color.rgb = color
|
||||
|
||||
|
||||
def shape(slide, l, t, w, h, fill, line=None):
|
||||
s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(l), Inches(t), Inches(w), Inches(h))
|
||||
s.fill.solid()
|
||||
s.fill.fore_color.rgb = fill
|
||||
if line is None:
|
||||
s.line.fill.background()
|
||||
else:
|
||||
s.line.color.rgb = line
|
||||
s.line.width = Pt(1.25)
|
||||
try:
|
||||
s.adjustments[0] = 0.08
|
||||
except Exception:
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
def shape_text(s, lines, size=SIZE_BODY, bold=False, color=WHITE, align=PP_ALIGN.CENTER):
|
||||
tf = s.text_frame
|
||||
tf.clear()
|
||||
tf.word_wrap = True
|
||||
if isinstance(lines, str):
|
||||
lines = lines.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = align
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(2)
|
||||
run = p.add_run()
|
||||
run.text = line
|
||||
set_font(run, size=size if i == 0 else max(size - 1, 10), bold=(bold and i == 0), color=color)
|
||||
|
||||
|
||||
def textbox(slide, l, t, w, h, text, size=SIZE_BODY, bold=False, color=DARK, align=PP_ALIGN.LEFT):
|
||||
box = slide.shapes.add_textbox(Inches(l), Inches(t), Inches(w), Inches(h))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = align
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(0)
|
||||
# multi-line support
|
||||
first = True
|
||||
for line in str(text).split("\n"):
|
||||
p = tf.paragraphs[0] if first else tf.add_paragraph()
|
||||
first = False
|
||||
p.alignment = align
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(2)
|
||||
run = p.add_run()
|
||||
run.text = line
|
||||
set_font(run, size=size, bold=bold, color=color)
|
||||
return box
|
||||
|
||||
|
||||
def paras(slide, l, t, w, h, lines, size=SIZE_BODY, color=DARK, spacing=4):
|
||||
box = slide.shapes.add_textbox(Inches(l), Inches(t), Inches(w), Inches(h))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
for i, line in enumerate(lines):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(spacing)
|
||||
run = p.add_run()
|
||||
run.text = line
|
||||
set_font(run, size=size, color=color)
|
||||
return box
|
||||
|
||||
|
||||
def table(slide, l, t, w, h, data, widths=None, fsize=SIZE_TABLE, header_color=NAVY):
|
||||
rows, cols = len(data), len(data[0])
|
||||
ts = slide.shapes.add_table(rows, cols, Inches(l), Inches(t), Inches(w), Inches(h))
|
||||
tb = ts.table
|
||||
if widths:
|
||||
for i, wi in enumerate(widths):
|
||||
tb.columns[i].width = Inches(wi)
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
cell = tb.cell(r, c)
|
||||
cell.text = ""
|
||||
p = cell.text_frame.paragraphs[0]
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
p.space_before = Pt(0)
|
||||
p.space_after = Pt(0)
|
||||
run = p.add_run()
|
||||
run.text = str(data[r][c])
|
||||
is_h = r == 0
|
||||
set_font(run, size=fsize + (1 if is_h else 0), bold=is_h, color=WHITE if is_h else DARK)
|
||||
cell.fill.solid()
|
||||
# colorful header / zebra
|
||||
if is_h:
|
||||
cell.fill.fore_color.rgb = header_color
|
||||
else:
|
||||
cell.fill.fore_color.rgb = SOFT if r % 2 == 0 else WHITE
|
||||
return ts
|
||||
|
||||
|
||||
def header(slide, title, subtitle, section_idx=0):
|
||||
color = SECTION_COLORS[section_idx % len(SECTION_COLORS)]
|
||||
shape(slide, 0, 0, 13.333, HEADER_H, NAVY)
|
||||
# colorful accent strip
|
||||
shape(slide, 0, HEADER_H, 13.333, 0.06, color)
|
||||
# left color badge
|
||||
badge = shape(slide, 0.25, 0.18, 0.12, 0.52, color)
|
||||
box = slide.shapes.add_textbox(Inches(0.55), Inches(0.1), Inches(12.3), Inches(0.7))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
run = p.add_run()
|
||||
run.text = title
|
||||
set_font(run, size=SIZE_H1, bold=True, color=WHITE)
|
||||
if subtitle:
|
||||
p2 = tf.add_paragraph()
|
||||
run2 = p2.add_run()
|
||||
run2.text = subtitle
|
||||
set_font(run2, size=SIZE_SMALL, color=RGBColor(0xB8, 0xD4, 0xF0))
|
||||
|
||||
|
||||
def footer(slide, page):
|
||||
shape(slide, 0, FOOTER_Y, 13.333, 7.5 - FOOTER_Y, NAVY)
|
||||
# rainbow dots
|
||||
colors = [CORAL, ORANGE, YELLOW, TEAL, BLUE, PURPLE]
|
||||
for i, c in enumerate(colors):
|
||||
shape(slide, 0.25 + i * 0.22, FOOTER_Y + 0.1, 0.14, 0.14, c)
|
||||
textbox(slide, 1.7, FOOTER_Y + 0.05, 9.5, 0.28,
|
||||
"智慧医院 AI 影像诊断与电子病历辅助决策系统 · 项目答辩 · 仅供教学演示",
|
||||
size=10, color=RGBColor(0xC5, 0xD8, 0xE8))
|
||||
textbox(slide, 11.4, FOOTER_Y + 0.05, 1.6, 0.28, f"{page} / {TOTAL}",
|
||||
size=10, bold=True, color=WHITE, align=PP_ALIGN.RIGHT)
|
||||
|
||||
|
||||
def fit_img(slide, path, l, t, max_w, max_h):
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return None
|
||||
with PILImage.open(path) as im:
|
||||
iw, ih = im.size
|
||||
aspect = iw / float(ih)
|
||||
w, h = max_w, max_w / aspect
|
||||
if h > max_h:
|
||||
h = max_h
|
||||
w = h * aspect
|
||||
return slide.shapes.add_picture(str(path), Inches(l), Inches(t), width=Inches(w), height=Inches(h))
|
||||
|
||||
|
||||
def card(slide, l, t, w, h, title, lines, color=BLUE, body_size=SIZE_BODY):
|
||||
shape(slide, l, t, w, h, WHITE, SOFT)
|
||||
band = shape(slide, l, t, w, 0.46, color)
|
||||
shape_text(band, [title], size=14, bold=True, color=WHITE)
|
||||
if lines:
|
||||
paras(slide, l + 0.15, t + 0.55, w - 0.3, h - 0.7, lines, size=body_size, color=DARK, spacing=4)
|
||||
|
||||
|
||||
# ===================== SLIDES =====================
|
||||
|
||||
def s01_cover(prs):
|
||||
slide = blank(prs)
|
||||
fit_img(slide, ASSETS / "cover.png", 0, 0, 13.333, 7.5)
|
||||
shape(slide, 1.0, 1.4, 11.3, 4.5, PANEL)
|
||||
textbox(slide, 1.3, 1.6, 10.7, 0.35, "PROJECT DEFENSE · SMART HOSPITAL",
|
||||
size=13, color=CYAN, align=PP_ALIGN.CENTER)
|
||||
textbox(slide, 1.3, 2.15, 10.7, 1.15, "智慧医院 AI 影像诊断与\n电子病历辅助决策系统",
|
||||
size=32, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
textbox(slide, 1.3, 3.45, 10.7, 0.4, "实训教学演示级 Web 应用 · 业务系统 + AI 微服务",
|
||||
size=15, color=RGBColor(0xB8, 0xD4, 0xF0), align=PP_ALIGN.CENTER)
|
||||
|
||||
chips = [
|
||||
("主题", "智慧医院", CORAL),
|
||||
("架构", "三端分离", ORANGE),
|
||||
("AI", "YOLO+RAG", TEAL),
|
||||
("前端", "Vue3", BLUE),
|
||||
("后端", "Boot3", PURPLE),
|
||||
("团队", "6 人协作", PINK),
|
||||
]
|
||||
for i, (k, v, c) in enumerate(chips):
|
||||
x = 1.35 + i * 1.8
|
||||
box = shape(slide, x, 4.15, 1.65, 0.95, c)
|
||||
shape_text(box, [k, v], size=12, bold=True, color=WHITE)
|
||||
|
||||
textbox(slide, 1.2, 5.5, 10.9, 0.35, "文档 v1.0 · 2026-07-27 · 严格依据《项目详细文档》",
|
||||
size=12, color=RGBColor(0xC5, 0xD8, 0xE8), align=PP_ALIGN.CENTER)
|
||||
textbox(slide, 1.2, 6.3, 10.9, 0.55,
|
||||
"声明:输出仅供教学实训与辅助决策演示,不能替代执业医师正式诊断与医疗文书。",
|
||||
size=11, color=RGBColor(0xE2, 0xEC, 0xF4), align=PP_ALIGN.CENTER)
|
||||
|
||||
|
||||
def s02_toc(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "目录 · 答辩叙事五篇章", "主题 → 需求 → 技术栈 → 功能 → 分工", 0)
|
||||
sections = [
|
||||
("01", "项目主题", "P3 – P4", "定位 · 双链路 · 目标 · 场景", BLUE, "主题"),
|
||||
("02", "项目需求", "P5 – P7", "痛点 · 角色 · 优先级 · 非功能", CORAL, "需求"),
|
||||
("03", "技术栈", "P8 – P10", "三层技术 · 架构 · 工程结构", PURPLE, "技术"),
|
||||
("04", "实现功能", "P11 – P14", "全景 · 影像 · 决策 · 管理安全", TEAL, "功能"),
|
||||
("05", "六人分工", "P15 – P16", "协作总览 · 职责 · 交付物", ORANGE, "分工"),
|
||||
("06", "演示总结", "P17 – P18", "演示路径 · 验证 · 致谢", PINK, "收束"),
|
||||
]
|
||||
for i, (num, title, pages, desc, color, tag) in enumerate(sections):
|
||||
col = i % 3
|
||||
row = i // 3
|
||||
x = 0.45 + col * 4.25
|
||||
y = 1.2 + row * 2.7
|
||||
shape(slide, x, y, 4.05, 2.45, WHITE, color)
|
||||
band = shape(slide, x, y, 4.05, 0.7, color)
|
||||
shape_text(band, [f"{num} {title}"], size=18, bold=True, color=WHITE)
|
||||
textbox(slide, x + 0.2, y + 0.9, 3.6, 0.35, pages, size=14, bold=True, color=color)
|
||||
textbox(slide, x + 0.2, y + 1.35, 3.6, 0.8, desc, size=13, color=DARK)
|
||||
footer(slide, 2)
|
||||
|
||||
|
||||
def s03_theme(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "一、项目主题 · 定位与双链路", "智慧医院 = 影像 AI 读片 + 病历 AI 决策", 0)
|
||||
fit_img(slide, ASSETS / "dual_chain.png", 0.3, 1.1, 12.7, 5.8)
|
||||
footer(slide, 3)
|
||||
|
||||
|
||||
def s04_goals_scenes(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "一、项目主题 · 建设目标与应用场景", "五维目标 + 四类演示场景", 0)
|
||||
fit_img(slide, ASSETS / "goals.png", 0.3, 1.1, 12.7, 2.5)
|
||||
fit_img(slide, ASSETS / "scenes.png", 0.3, 3.7, 12.7, 3.15)
|
||||
footer(slide, 4)
|
||||
|
||||
|
||||
def s05_req_pain(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "二、项目需求 · 背景痛点与改造", "解决传统教学 HIS 的五类不足", 1)
|
||||
fit_img(slide, ASSETS / "pain.png", 0.25, 1.1, 8.0, 4.0)
|
||||
|
||||
data = [
|
||||
["问题", "传统表现", "本项目对策"],
|
||||
["业务割裂", "缺统一视图", "患者 360° 档案"],
|
||||
["AI 假数据", "随机/关键字", "YOLO + LLM/RAG"],
|
||||
["技术陈旧", "Thymeleaf 耦合", "Vue3 前后端分离"],
|
||||
["权限薄弱", "仅 Session", "JWT + 角色双端"],
|
||||
["不可降级", "依赖失败中断", "规则/模板兜底"],
|
||||
]
|
||||
table(slide, 8.4, 1.1, 4.55, 4.0, data, [1.2, 1.5, 1.85], fsize=10, header_color=CORAL)
|
||||
|
||||
nfr_note = shape(slide, 0.3, 5.3, 12.7, 1.55, WHITE, TEAL)
|
||||
shape_text(
|
||||
nfr_note,
|
||||
[
|
||||
"改造结论(文档 2.1)",
|
||||
"在原始 Spring Boot + Thymeleaf 原型上完成改造 → 当前「前后端分离 + AI 微服务」版本",
|
||||
"约束:实训/答辩级,非生产 HIS/PACS;影像以 JPG/PNG 为主;YOLO 为演示级需医师复核",
|
||||
],
|
||||
size=12,
|
||||
color=DARK,
|
||||
align=PP_ALIGN.LEFT,
|
||||
)
|
||||
footer(slide, 5)
|
||||
|
||||
|
||||
def s06_req_roles(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "二、项目需求 · 角色诉求与功能优先级", "三角色 + P0/P1/P2 需求看板", 1)
|
||||
|
||||
roles = [
|
||||
["角色", "枚举", "核心诉求"],
|
||||
["系统管理员", "ADMIN", "用户 · AI 配置 · YOLO 权重 · 运维"],
|
||||
["临床医生", "DOCTOR", "患者病历 · 决策 · 预约 · 看报告"],
|
||||
["影像医师", "RADIOLOGIST", "影像登记上传 · AI 诊断 · 审阅"],
|
||||
["访客", "—", "仅登录页"],
|
||||
]
|
||||
table(slide, 0.3, 1.1, 12.7, 2.0, roles, [2.2, 2.3, 8.2], fsize=12, header_color=BLUE)
|
||||
|
||||
fit_img(slide, ASSETS / "priority.png", 0.3, 3.25, 12.7, 3.65)
|
||||
footer(slide, 6)
|
||||
|
||||
|
||||
def s07_req_nfr(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "二、项目需求 · 非功能需求", "性能 · 可用性 · 安全 · 可维护 · 可扩展 · 兼容", 1)
|
||||
fit_img(slide, ASSETS / "nfr.png", 0.3, 1.15, 12.7, 2.4)
|
||||
|
||||
data = [
|
||||
["类别", "要求", "项目落地"],
|
||||
["性能", "诊断不阻塞 HTTP", "@Async 线程池 + 前端有限轮询"],
|
||||
["可用性", "无 MySQL 可演示", "默认 H2 + DataInitializer 种子"],
|
||||
["安全", "鉴权/加密/CORS", "Security + JWT + BCrypt"],
|
||||
["可维护", "统一响应异常", "Result<T> + GlobalExceptionHandler"],
|
||||
["可扩展", "AI 与业务解耦", "FastAPI 独立 · ai.service.base-url"],
|
||||
["兼容", "大模型可切换", "OpenAI 兼容(DeepSeek/Qwen)"],
|
||||
]
|
||||
table(slide, 0.3, 3.7, 12.7, 3.15, data, [1.6, 3.5, 7.6], fsize=11, header_color=PURPLE)
|
||||
footer(slide, 7)
|
||||
|
||||
|
||||
def s08_tech_stack(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "三、技术栈 · 总体一览", "版本号严格对齐文档附录 A", 2)
|
||||
fit_img(slide, ASSETS / "tech.png", 0.3, 1.1, 12.7, 3.5)
|
||||
|
||||
data = [
|
||||
["层级", "关键组件", "版本"],
|
||||
["前端", "Vue / Vite / Element Plus / Pinia / ECharts", "3.5.10 / 5.4.8 / 2.8.4 / 2.2.4 / 5.5.1"],
|
||||
["业务端", "Spring Boot / Java / jjwt", "3.3.4 / 17 / 0.12.6"],
|
||||
["数据库", "H2 默认 · MySQL 可选", "mem:smart_hospital / profile=mysql"],
|
||||
["AI", "FastAPI / YOLO / LangChain / LLM", "≥0.110 / Ultralytics / ≥0.2 / DeepSeek"],
|
||||
]
|
||||
table(slide, 0.3, 4.75, 12.7, 2.1, data, [1.5, 6.0, 5.2], fsize=11, header_color=PURPLE)
|
||||
footer(slide, 8)
|
||||
|
||||
|
||||
def s09_tech_arch(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "三、技术栈 · 系统逻辑架构", "Vue :5173 → Spring :8080 → H2/MySQL & FastAPI :8001", 2)
|
||||
fit_img(slide, ASSETS / "architecture.png", 0.25, 1.05, 12.8, 5.9)
|
||||
footer(slide, 9)
|
||||
|
||||
|
||||
def s10_tech_modules(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "三、技术栈 · 工程结构与数据落盘", "frontend · smart-hospital · ai-service", 2)
|
||||
fit_img(slide, ASSETS / "modules.png", 0.25, 1.1, 8.5, 5.7)
|
||||
|
||||
card(slide, 8.95, 1.1, 4.0, 5.7, "关键配置与落盘", [
|
||||
"• 前端代理 /api → :8080",
|
||||
"• jwt.* 密钥/过期/前缀",
|
||||
"• cors → localhost:5173",
|
||||
"• ai.service.base-url:8001",
|
||||
"• ./uploads/images 影像",
|
||||
"• ./data/ai/settings.json",
|
||||
"• chat-history.json 对话",
|
||||
"• ai-service/data/weights/",
|
||||
"• app/knowledge/ MD 知识",
|
||||
"• multipart 最大约 500MB",
|
||||
"• H2 控制台 /h2-console",
|
||||
"• 启动序:AI→业务→前端",
|
||||
], VIOLET, body_size=12)
|
||||
footer(slide, 10)
|
||||
|
||||
|
||||
def s11_feat_panorama(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "四、实现功能 · 功能全景", "身份权限 · 主数据 · 诊疗业务 · 智能能力", 3)
|
||||
fit_img(slide, ASSETS / "features.png", 0.2, 1.05, 9.0, 5.85)
|
||||
|
||||
routes = [
|
||||
"/login 登录",
|
||||
"/dashboard 仪表盘",
|
||||
"/patients 患者",
|
||||
"/imaging 影像",
|
||||
"/emrs 电子病历",
|
||||
"/appointments 预约",
|
||||
"/ai-assistant 助手",
|
||||
"/ai-knowledge 知识库",
|
||||
"/ai-settings ADMIN",
|
||||
"/ai-yolo ADMIN",
|
||||
"/users ADMIN",
|
||||
]
|
||||
card(slide, 9.4, 1.1, 3.55, 5.8, "前端路由一览", ["• " + r for r in routes], TEAL, body_size=11)
|
||||
footer(slide, 11)
|
||||
|
||||
|
||||
def s12_feat_imaging(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "四、实现功能 · 影像 AI 诊断(深度)", "状态机 · YOLO · 分段报告 · 可降级", 3)
|
||||
|
||||
card(slide, 0.25, 1.1, 4.0, 2.2, "检查登记能力", [
|
||||
"• X_RAY / CT / MRI / ULTRASOUND",
|
||||
"• 患者、部位、上传/路径",
|
||||
"• 关键字/状态/类型筛选",
|
||||
"• 状态 KPI 卡片快速过滤",
|
||||
], BLUE, body_size=11)
|
||||
fit_img(slide, ASSETS / "state.png", 4.4, 1.1, 8.55, 2.2)
|
||||
|
||||
fit_img(slide, ASSETS / "yolo.png", 0.25, 3.5, 5.4, 3.35)
|
||||
card(slide, 5.85, 3.5, 7.1, 3.35, "结果展示清单", [
|
||||
"① 诊断印象 + 置信度仪表盘",
|
||||
"② 原图 vs YOLO 标注图对比",
|
||||
"③ 影像所见分段 + 建议列表",
|
||||
"④ 检测明细:类别/置信度/bbox",
|
||||
"⑤ 完整报告:头/所见/印象/建议/声明",
|
||||
"⑥ AI 不可用 → Spring 本地规则降级",
|
||||
], CORAL, body_size=12)
|
||||
footer(slide, 12)
|
||||
|
||||
|
||||
def s13_feat_emr_biz(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "四、实现功能 · 病历决策与业务模块", "辅助决策七块输出 + 仪表盘/患者/预约", 3)
|
||||
fit_img(slide, ASSETS / "decision.png", 0.25, 1.1, 12.8, 2.7)
|
||||
|
||||
cards = [
|
||||
("仪表盘", BLUE, ["KPI 多维统计", "近7日趋势 ECharts", "类型/状态分布饼图", "快捷入口与待办"]),
|
||||
("患者 360°", TEAL, ["CRUD + 搜索分页", "证件/联系/既往史", "关联影像/病历/预约", "抽屉档案计数"]),
|
||||
("预约挂号", ORANGE, ["新建编辑删除", "预约→确认→完成", "取消/未到诊", "按日筛选统计"]),
|
||||
("关键词增强", PURPLE, ["高血压/糖尿病", "肺炎/结节", "模板+RAG 更明显", "LLM 可进一步增强"]),
|
||||
]
|
||||
for i, (t, c, lines) in enumerate(cards):
|
||||
card(slide, 0.25 + i * 3.25, 4.0, 3.1, 2.85, t, ["• " + x for x in lines], c, body_size=11)
|
||||
footer(slide, 13)
|
||||
|
||||
|
||||
def s14_feat_admin(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "四、实现功能 · 管理端、安全与接口", "AI 配置 · 权限双端 · 统一响应 · 实体关系", 3)
|
||||
|
||||
mods = [
|
||||
("AI 助手", ["Markdown 渲染", "DOMPurify 消毒", "历史文件持久化"], CYAN),
|
||||
("知识库", ["文档 CRUD 启停", "RAG 管线配合", "内置医学片段"], PURPLE),
|
||||
("AI 配置", ["仅 ADMIN", "URL/Key/Model", "同步 FastAPI"], ORANGE),
|
||||
("YOLO 管理", ["权重上传激活", "real/demo 模式", "推理统计"], CORAL),
|
||||
]
|
||||
for i, (t, lines, c) in enumerate(mods):
|
||||
card(slide, 0.25 + i * 3.25, 1.1, 3.1, 2.15, t, ["• " + x for x in lines], c, body_size=11)
|
||||
|
||||
fit_img(slide, ASSETS / "er.png", 0.25, 3.4, 6.5, 3.45)
|
||||
|
||||
data = [
|
||||
["接口/能力", "权限"],
|
||||
["POST /api/auth/login", "公开"],
|
||||
["患者/影像/病历/预约", "已登录"],
|
||||
["AI 诊断 analyze/result", "已登录"],
|
||||
["病历 ai-suggestions", "已登录"],
|
||||
["用户管理 /users", "ADMIN"],
|
||||
["AI 配置 / YOLO", "ADMIN"],
|
||||
]
|
||||
table(slide, 6.95, 3.4, 6.0, 3.45, data, [4.0, 2.0], fsize=11, header_color=INDIGO)
|
||||
footer(slide, 14)
|
||||
|
||||
|
||||
def s15_team_overview(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "五、六人分工 · 协作总览", "按三端工程与业务模块划分(姓名可替换)", 4)
|
||||
fit_img(slide, ASSETS / "team.png", 0.25, 1.05, 12.8, 5.9)
|
||||
footer(slide, 15)
|
||||
|
||||
|
||||
def s16_team_detail(prs):
|
||||
"""Balanced layout: compact table on top + full-width swimlane below (no tiny corner image)."""
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "五、六人分工 · 职责与交付物明细", "文档无具名分工 → 按模块可落地的 6 人方案", 4)
|
||||
|
||||
data = [
|
||||
["成员", "角色定位", "主要职责", "关键交付物"],
|
||||
["成员A", "前端基础/权限", "布局、登录、路由守卫、Pinia、个人中心", "BasicLayout · Login · router"],
|
||||
["成员B", "前端业务", "仪表盘、患者360°、预约挂号、图表", "Dashboard · Patients · Appointments"],
|
||||
["成员C", "前端智能交互", "影像报告弹窗、病历决策Dialog、AI助手/知识库页", "Imaging · EMR · AI views"],
|
||||
["成员D", "后端主数据/安全", "JWT安全、用户、患者、预约、统一响应", "Security · User/Patient API"],
|
||||
["成员E", "后端AI桥接", "异步诊断、AiClient、决策对接、降级逻辑", "AIDiagnosis · Decision 服务"],
|
||||
["成员F", "AI微服务", "YOLO检测、报告/决策、RAG、权重与LLM配置", "ai-service 全模块"],
|
||||
]
|
||||
# 上半:表格约占 2.9",行高更匀称
|
||||
table(slide, 0.3, 1.08, 12.7, 2.95, data, [1.3, 2.2, 5.0, 4.2], fsize=12, header_color=ORANGE)
|
||||
|
||||
# 下半:全宽泳道图(宽扁比例,贴满宽度,与上表约 1:1 视觉权重)
|
||||
fit_img(slide, ASSETS / "team_lane.png", 0.3, 4.15, 12.7, 2.8)
|
||||
footer(slide, 16)
|
||||
|
||||
|
||||
def s17_demo(prs):
|
||||
slide = blank(prs)
|
||||
bg(slide)
|
||||
header(slide, "演示路径 · 账号 · 验证清单", "答辩现场可按 7 步剧本走通", 5)
|
||||
|
||||
accounts = [
|
||||
["用户名", "密码", "角色", "用途"],
|
||||
["admin", "admin123", "ADMIN", "用户 / AI 配置 / YOLO"],
|
||||
["doctor1", "pass123", "DOCTOR", "临床主演示"],
|
||||
["radio1", "radio123", "RADIOLOGIST", "影像演示"],
|
||||
]
|
||||
table(slide, 0.3, 1.1, 12.7, 1.7, accounts, [2.2, 2.5, 2.8, 5.2], fsize=12, header_color=TEAL)
|
||||
|
||||
fit_img(slide, ASSETS / "demo.png", 0.3, 3.0, 12.7, 1.85)
|
||||
|
||||
checks = [
|
||||
["验证项", "期望"],
|
||||
["三端启动 5173 可登录", "通过"],
|
||||
["admin 可见用户管理;doctor 访问 /users → 403", "通过"],
|
||||
["影像 AI 至 COMPLETED,报告分段清晰", "通过"],
|
||||
["病历 AI 建议含多类内容 + RAG 来源", "通过"],
|
||||
["预约状态可流转;改密后需重登", "通过"],
|
||||
["关闭 AI 后诊断仍返回降级结果", "通过"],
|
||||
]
|
||||
table(slide, 0.3, 5.0, 12.7, 1.9, checks, [8.5, 4.2], fsize=11, header_color=BLUE)
|
||||
footer(slide, 17)
|
||||
|
||||
|
||||
def s18_end(prs):
|
||||
slide = blank(prs)
|
||||
fit_img(slide, ASSETS / "closing.png", 0, 0, 13.333, 7.5)
|
||||
|
||||
textbox(slide, 1, 1.0, 11.3, 0.55, "总结与致谢", size=30, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
textbox(slide, 1.2, 1.65, 10.9, 0.45,
|
||||
"主题清晰 · 需求可验 · 技术可讲 · 功能可演示 · 分工可落地",
|
||||
size=14, color=CYAN, align=PP_ALIGN.CENTER)
|
||||
|
||||
points = [
|
||||
("主题", "双链路智慧医院", CORAL),
|
||||
("需求", "P0–P2 + 非功能", ORANGE),
|
||||
("技术", "Vue3+Boot+FastAPI", PURPLE),
|
||||
("功能", "YOLO·RAG·全业务", TEAL),
|
||||
("协作", "6 人三端分工", BLUE),
|
||||
("边界", "教学演示非临床", PINK),
|
||||
]
|
||||
for i, (t, d, c) in enumerate(points):
|
||||
x = 0.7 + (i % 6) * 2.1
|
||||
box = shape(slide, x, 2.4, 1.95, 1.5, c)
|
||||
shape_text(box, [t, d], size=12, bold=True, color=WHITE)
|
||||
|
||||
textbox(slide, 1, 4.2, 11.3, 0.5, "谢谢聆听 · 欢迎提问", size=26, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
|
||||
textbox(slide, 1.2, 5.0, 10.9, 0.9,
|
||||
"声明:仅供教学实训与辅助决策演示,不能替代执业医师正式诊断。\n"
|
||||
"依据:《项目详细文档.md》v1.0 · 分工表中成员名可按实际名单替换",
|
||||
size=12, color=RGBColor(0xC5, 0xD8, 0xE8), align=PP_ALIGN.CENTER)
|
||||
|
||||
# note about team
|
||||
textbox(slide, 1.2, 6.2, 10.9, 0.5,
|
||||
"六人姓名未在文档中给出,PPT 使用「成员A–F」占位,答辩前请替换为真实姓名。",
|
||||
size=11, color=YELLOW, align=PP_ALIGN.CENTER)
|
||||
|
||||
|
||||
def main():
|
||||
if not ASSETS.exists():
|
||||
raise SystemExit("请先运行 gen_assets_v2.py 生成 assets_v2/")
|
||||
|
||||
prs = Presentation()
|
||||
prs.slide_width = SLIDE_W
|
||||
prs.slide_height = SLIDE_H
|
||||
|
||||
s01_cover(prs)
|
||||
s02_toc(prs)
|
||||
s03_theme(prs)
|
||||
s04_goals_scenes(prs)
|
||||
s05_req_pain(prs)
|
||||
s06_req_roles(prs)
|
||||
s07_req_nfr(prs)
|
||||
s08_tech_stack(prs)
|
||||
s09_tech_arch(prs)
|
||||
s10_tech_modules(prs)
|
||||
s11_feat_panorama(prs)
|
||||
s12_feat_imaging(prs)
|
||||
s13_feat_emr_biz(prs)
|
||||
s14_feat_admin(prs)
|
||||
s15_team_overview(prs)
|
||||
s16_team_detail(prs)
|
||||
s17_demo(prs)
|
||||
s18_end(prs)
|
||||
|
||||
try:
|
||||
prs.save(OUT)
|
||||
print("Saved:", OUT)
|
||||
except PermissionError:
|
||||
prs.save(OUT_ALT)
|
||||
print("原文件占用,另存:", OUT_ALT)
|
||||
print("Slides:", len(prs.slides))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from pathlib import Path
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
|
||||
p = Path(r"E:\桌面\实训项目\smart-hospital\ppt\智慧医院_AI影像诊断与电子病历辅助决策系统_答辩PPT.pptx")
|
||||
prs = Presentation(str(p))
|
||||
|
||||
|
||||
def box(sh):
|
||||
return (sh.left, sh.top, sh.left + sh.width, sh.top + sh.height)
|
||||
|
||||
|
||||
def area(b):
|
||||
return max(0, b[2] - b[0]) * max(0, b[3] - b[1])
|
||||
|
||||
|
||||
def inter(a, b):
|
||||
l = max(a[0], b[0])
|
||||
t = max(a[1], b[1])
|
||||
r = min(a[2], b[2])
|
||||
btm = min(a[3], b[3])
|
||||
if r <= l or btm <= t:
|
||||
return 0
|
||||
return (r - l) * (btm - t)
|
||||
|
||||
|
||||
def label(sh):
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
||||
return "[IMG]"
|
||||
if sh.has_table:
|
||||
return "[TABLE]"
|
||||
t = ""
|
||||
if hasattr(sh, "text") and sh.text:
|
||||
t = sh.text.replace("\n", " ").strip()[:50]
|
||||
return t or f"shape#{sh.shape_id}"
|
||||
|
||||
|
||||
for si, s in enumerate(prs.slides, 1):
|
||||
shapes = list(s.shapes)
|
||||
boxes = [(box(sh), label(sh), sh) for sh in shapes]
|
||||
issues = []
|
||||
for i in range(len(boxes)):
|
||||
for j in range(i + 1, len(boxes)):
|
||||
bi, bj = boxes[i][0], boxes[j][0]
|
||||
ia = inter(bi, bj)
|
||||
if ia <= 0:
|
||||
continue
|
||||
min_a = min(area(bi), area(bj))
|
||||
if min_a == 0:
|
||||
continue
|
||||
ratio = ia / min_a
|
||||
# intentional: text on colored bar, footer text on footer bar
|
||||
# report if ratio high and not pure containment of tiny text in bar
|
||||
if ratio < 0.25:
|
||||
continue
|
||||
t1, t2 = boxes[i][1], boxes[j][1]
|
||||
# skip text fully inside same-color header/footer style if one is short page num
|
||||
issues.append((ratio, t1, t2, bi, bj))
|
||||
# also content past footer
|
||||
footer_y = int(7.15 * 914400)
|
||||
overflow = []
|
||||
for b, lab, sh in boxes:
|
||||
if b[1] < footer_y < b[3] and b[3] - footer_y > 50000 and "仅供教学" not in lab and " / " not in lab:
|
||||
# shape crosses into footer zone
|
||||
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE or sh.has_table or (hasattr(sh, "text") and len(sh.text) > 20):
|
||||
overflow.append(lab)
|
||||
if issues or overflow:
|
||||
print(f"=== Slide {si} overlaps={len(issues)} overflow={len(overflow)} ===")
|
||||
for ratio, t1, t2, bi, bj in sorted(issues, reverse=True)[:10]:
|
||||
print(f" r={ratio:.2f}")
|
||||
print(f" A: {t1}")
|
||||
print(f" B: {t2}")
|
||||
print(
|
||||
f" A inches: L={bi[0]/914400:.2f} T={bi[1]/914400:.2f} R={bi[2]/914400:.2f} B={bi[3]/914400:.2f}"
|
||||
)
|
||||
print(
|
||||
f" B inches: L={bj[0]/914400:.2f} T={bj[1]/914400:.2f} R={bj[2]/914400:.2f} B={bj[3]/914400:.2f}"
|
||||
)
|
||||
for o in overflow:
|
||||
print(f" FOOTER CROSS: {o}")
|
||||
@@ -0,0 +1,722 @@
|
||||
# -*- 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)
|
||||
@@ -0,0 +1,797 @@
|
||||
# -*- 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)
|
||||
@@ -0,0 +1,775 @@
|
||||
# 智慧医院 AI 影像诊断与电子病历辅助决策系统
|
||||
|
||||
## 项目详细文档
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 项目名称 | 智慧医院 AI 影像诊断与电子病历辅助决策系统(Smart Hospital) |
|
||||
| 项目类型 | 高校/实训教学演示级 Web 应用 |
|
||||
| 文档版本 | 1.0 |
|
||||
| 文档日期 | 2026-07-27 |
|
||||
| 代码根目录 | `smart-hospital/` |
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [项目主题](#一项目主题)
|
||||
2. [需求分析](#二需求分析)
|
||||
3. [项目功能](#三项目功能)
|
||||
4. [技术栈](#四技术栈)
|
||||
5. [系统架构](#五系统架构)
|
||||
6. [目录与模块结构](#六目录与模块结构)
|
||||
7. [核心业务流程](#七核心业务流程)
|
||||
8. [数据模型概要](#八数据模型概要)
|
||||
9. [接口与权限](#九接口与权限)
|
||||
10. [部署与运行](#十部署与运行)
|
||||
11. [演示账号与推荐路径](#十一演示账号与推荐路径)
|
||||
12. [设计说明与边界](#十二设计说明与边界)
|
||||
|
||||
---
|
||||
|
||||
## 一、项目主题
|
||||
|
||||
### 1.1 主题定位
|
||||
|
||||
本项目以 **「智慧医院」** 为主题,围绕医院日常诊疗中的两条核心链路展开:
|
||||
|
||||
1. **医学影像检查 → AI 辅助读片 → 结构化诊断报告**
|
||||
2. **电子病历录入 → AI 辅助决策(治疗 / 用药 / 护理 / 随访)→ 知识库引用**
|
||||
|
||||
在传统 HIS(医院信息系统)业务能力之上,引入 **计算机视觉(YOLO)** 与 **大语言模型 / RAG 知识检索**,形成「业务系统 + AI 微服务」的混合架构,用于实训教学、课程答辩与功能演示。
|
||||
|
||||
### 1.2 建设目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| 业务闭环 | 覆盖登录鉴权、患者档案、影像检查、病历、预约挂号、用户管理等完整业务面 |
|
||||
| AI 可演示 | 影像侧可真实跑 YOLO 权重检测;报告与决策侧可接 DeepSeek 等 OpenAI 兼容大模型,也可模板降级 |
|
||||
| 前后端分离 | Vue 3 SPA + Spring Boot REST + FastAPI AI 服务,职责清晰、便于分模块讲解 |
|
||||
| 可离线实训 | 默认 H2 内存库,无需强制安装 MySQL;AI 服务不可用时业务仍可降级运行 |
|
||||
| 安全可讲 | JWT 无状态认证、角色权限(ADMIN / DOCTOR / RADIOLOGIST)、BCrypt 密码、前后端双重路由守卫 |
|
||||
|
||||
### 1.3 应用场景(教学/演示)
|
||||
|
||||
- 影像科:上传胸片等影像 → 一键 AI 诊断 → 查看 YOLO 标注框、置信度与分段报告
|
||||
- 临床医生:书写病历(如含「高血压 / 肺炎 / 糖尿病 / 结节」等关键词)→ 获取治疗、护理、随访建议与知识库来源
|
||||
- 管理员:配置 LLM 接口、管理知识库文档、切换/上传 YOLO 权重、管理系统用户
|
||||
- 挂号窗口:预约登记与状态流转(预约 → 确认 → 完成 / 取消 / 未到诊)
|
||||
|
||||
### 1.4 项目声明
|
||||
|
||||
> 本系统输出内容 **仅供教学实训与辅助决策演示**,**不能替代执业医师的正式诊断与医疗文书**。涉及真实患者数据与临床部署时,需另行满足医疗信息化、隐私与合规要求。
|
||||
|
||||
---
|
||||
|
||||
## 二、需求分析
|
||||
|
||||
### 2.1 背景与问题
|
||||
|
||||
传统教学型医院管理系统往往只做 CRUD 与简单页面,存在以下不足:
|
||||
|
||||
| 问题 | 表现 |
|
||||
|------|------|
|
||||
| 业务割裂 | 患者、影像、病历、预约缺少统一档案视图 |
|
||||
| AI 仅「假数据」 | 随机文案或关键字匹配,无法展示真实检测框与模型链路 |
|
||||
| 技术栈陈旧 | 早期版本以 Thymeleaf 服务端渲染为主,前后端耦合 |
|
||||
| 权限薄弱 | 仅 Session 判断登录,缺少角色级接口保护 |
|
||||
| 不可降级 | 外部依赖一旦失败,整条演示链路中断 |
|
||||
|
||||
本项目在原始 Spring Boot + Thymeleaf 原型基础上完成改造,形成当前 **前后端分离 + AI 微服务** 版本,以解决上述问题。
|
||||
|
||||
### 2.2 用户角色与诉求
|
||||
|
||||
| 角色 | 代码枚举 | 核心诉求 |
|
||||
|------|----------|----------|
|
||||
| 系统管理员 | `ADMIN` | 用户管理、AI 大模型配置、YOLO 权重管理、全局运维 |
|
||||
| 临床医生 | `DOCTOR` | 患者与病历、辅助决策、预约、查看影像报告 |
|
||||
| 影像医师 | `RADIOLOGIST` | 影像登记、上传、触发 AI 诊断、审阅标注图与报告 |
|
||||
| 访客/未登录 | — | 仅可访问登录页 |
|
||||
|
||||
### 2.3 功能需求(按优先级)
|
||||
|
||||
#### P0 — 必须具备
|
||||
|
||||
| 编号 | 需求 | 验收要点 |
|
||||
|------|------|----------|
|
||||
| F-01 | 用户登录 / 登出 / 修改密码 | JWT 签发与校验;密码 BCrypt;修改后需重新登录 |
|
||||
| F-02 | 患者档案 CRUD + 搜索分页 | 姓名等关键字;360° 档案关联影像 / 病历 / 预约 |
|
||||
| F-03 | 影像检查 CRUD + 文件上传 | 支持 X_RAY / CT / MRI / ULTRASOUND |
|
||||
| F-04 | AI 影像诊断 | 异步状态机 `PENDING → ANALYZING → COMPLETED / ERROR`;可查看结果 |
|
||||
| F-05 | 电子病历 CRUD + AI 辅助决策 | 治疗 / 用药 / 护理 / 随访 / 风险 / 冲突 / RAG 来源 |
|
||||
| F-06 | 角色权限控制 | 前端路由 `meta.roles` + 后端 `@PreAuthorize` |
|
||||
|
||||
#### P1 — 重要增强
|
||||
|
||||
| 编号 | 需求 | 验收要点 |
|
||||
|------|------|----------|
|
||||
| F-07 | 预约挂号全流程 | 状态流转、按日筛选、统计卡片 |
|
||||
| F-08 | 仪表盘可视化 | KPI、近 7 日趋势、检查类型/诊断状态分布 |
|
||||
| F-09 | AI 对话助手 | 多轮对话、历史持久化(文件)、Markdown 渲染 |
|
||||
| F-10 | 知识库管理 | 文档增删改查,供 RAG / 决策引用 |
|
||||
| F-11 | AI 配置(管理员) | LLM Base URL / API Key / Model 配置,并同步至 FastAPI |
|
||||
| F-12 | YOLO 权重管理(管理员) | 权重列表、激活、上传统计 |
|
||||
|
||||
#### P2 — 体验与健壮性
|
||||
|
||||
| 编号 | 需求 | 验收要点 |
|
||||
|------|------|----------|
|
||||
| F-13 | AI 服务不可用时业务降级 | Spring 侧本地规则模拟,页面仍可演示 |
|
||||
| F-14 | 弹窗 / 抽屉不被布局裁切 | `append-to-body`、限高滚动、表单左右留白协调 |
|
||||
| F-15 | 报告分段可读 | 影像所见 / 诊断印象 / 建议 / 声明分块展示 |
|
||||
| F-16 | 页面切换过渡与仪表盘体验 | 路由过渡、半透明卡片叠背景图等 |
|
||||
|
||||
### 2.4 非功能需求
|
||||
|
||||
| 类别 | 要求 | 项目落地 |
|
||||
|------|------|----------|
|
||||
| 性能 | 诊断异步,避免阻塞 HTTP 线程 | `@Async` + 线程池;前端轮询(有上限,卸载时清理) |
|
||||
| 可用性 | 无 MySQL 亦可演示 | 默认 H2 内存库 + `DataInitializer` 种子数据 |
|
||||
| 安全 | 接口鉴权、密码加密、CORS 白名单 | Spring Security + JWT;`cors.allowed-origins` |
|
||||
| 可维护 | 统一响应与异常 | `Result<T>` + `GlobalExceptionHandler` |
|
||||
| 可扩展 | AI 与业务解耦 | FastAPI 独立进程;配置 `ai.service.base-url` |
|
||||
| 兼容 | 大模型厂商可切换 | OpenAI 兼容协议(DeepSeek / Qwen 等) |
|
||||
|
||||
### 2.5 约束与假设
|
||||
|
||||
- 面向 **实训 / 课程设计 / 答辩演示**,非生产级 HIS 或 PACS 替代品。
|
||||
- 医学影像以常见图片格式为主(JPG / PNG 等),DICOM 完整工作流未作为重点。
|
||||
- YOLO 权重与检测类别为演示级配置,输出置信度与框选结果需医师复核。
|
||||
- H2 内存模式下进程退出即丢库内业务数据;AI 配置与对话历史另存 `./data/ai/` 文件,可跨重启保留。
|
||||
|
||||
---
|
||||
|
||||
## 三、项目功能
|
||||
|
||||
### 3.1 功能总览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 智慧医院功能全景 │
|
||||
├──────────────┬──────────────┬──────────────┬────────────────────┤
|
||||
│ 身份与权限 │ 业务主数据 │ 诊疗业务 │ 智能能力 │
|
||||
├──────────────┼──────────────┼──────────────┼────────────────────┤
|
||||
│ 登录 / JWT │ 患者管理 │ 影像检查 │ YOLO 影像检测 │
|
||||
│ 修改密码 │ 用户管理 │ 电子病历 │ AI 诊断报告 │
|
||||
│ 角色菜单 │ 知识库文档 │ 预约挂号 │ EMR 辅助决策 + RAG │
|
||||
│ 头像上传 │ │ 仪表盘统计 │ AI 对话助手 │
|
||||
│ │ │ │ LLM / YOLO 管理端 │
|
||||
└──────────────┴──────────────┴──────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 功能模块详述
|
||||
|
||||
#### 3.2.1 登录与个人中心
|
||||
|
||||
- 账号密码登录,返回 JWT 与用户信息,前端 `localStorage` 持久化(约 24h 过期,见 `jwt.expiration-ms`)。
|
||||
- 右上角用户菜单:修改密码、更换头像。
|
||||
- 未登录访问业务页自动跳转登录;已登录访问登录页跳转仪表盘。
|
||||
|
||||
#### 3.2.2 仪表盘(Dashboard)
|
||||
|
||||
- 多项 KPI:患者数、影像数、病历数、预约数及状态分布等。
|
||||
- 近 7 天业务趋势折线 / 柱状图(ECharts)。
|
||||
- 检查类型分布、诊断状态分布饼图。
|
||||
- 快捷入口与待办提示,便于演示导览。
|
||||
|
||||
#### 3.2.3 患者管理
|
||||
|
||||
- 分页列表、关键字搜索(姓名等)。
|
||||
- 新增 / 编辑 / 删除:姓名、性别、年龄、身份证、电话、地址、既往病史。
|
||||
- **患者 360° 档案**(抽屉):基本信息 + 关联影像记录、电子病历、预约列表及计数统计。
|
||||
- 表单弹窗采用顶栏标签与等宽两列布局,避免左右留白不均。
|
||||
|
||||
#### 3.2.4 影像诊断
|
||||
|
||||
- 检查登记:选择患者、检查类型(X 光 / CT / MRI / 超声)、部位、上传影像或填写路径。
|
||||
- 列表筛选:关键字、状态、检查类型;状态 KPI 卡片可快速过滤。
|
||||
- **AI 诊断**:触发后状态流转;完成后展示:
|
||||
- 诊断印象与置信度仪表盘
|
||||
- 原始影像 vs YOLO 标注图对比
|
||||
- 影像所见(分段段落)
|
||||
- 建议(编号列表)
|
||||
- 检测明细表(类别、置信度、bbox)
|
||||
- 完整报告(报告头 / 所见 / 印象 / 建议 / 声明 分块卡片)
|
||||
- AI 服务不可用时,业务后端可降级为本地规则结果,保证演示不断链。
|
||||
|
||||
#### 3.2.5 电子病历
|
||||
|
||||
- 病历录入:患者、就诊日期、主诉、现病史、体格检查、诊断、治疗方案、用药、随访。
|
||||
- 保存后可自动弹出 **AI 辅助决策**;列表亦可再次查看。
|
||||
- 决策内容包括:治疗建议表、用药建议表、护理建议、随访计划、风险评估、药物冲突提示、知识库引用(RAG)。
|
||||
- 诊断含「高血压 / 糖尿病 / 肺炎 / 结节」等关键词时,模板/RAG 效果更明显;配置 LLM 后由大模型增强。
|
||||
|
||||
#### 3.2.6 预约挂号
|
||||
|
||||
- 新建 / 编辑 / 删除预约。
|
||||
- 状态流转:预约 → 确认 → 完成 / 取消 / 未到诊。
|
||||
- 按日期筛选、统计卡片、详情抽屉。
|
||||
|
||||
#### 3.2.7 AI 助手
|
||||
|
||||
- 对话式问答界面,支持 Markdown 渲染(`marked` + `DOMPurify` 消毒)。
|
||||
- 对话历史持久化到业务端 `./data/ai/chat-history.json`(不依赖 H2 是否清空)。
|
||||
|
||||
#### 3.2.8 知识库
|
||||
|
||||
- 知识文档的新增、编辑、查看、启用/停用。
|
||||
- 与 AI 服务 RAG 管线配合;AI 服务内置医学相关 Markdown 知识片段(如高血压、肺炎、肺结节、糖尿病等)。
|
||||
|
||||
#### 3.2.9 AI 配置(仅管理员)
|
||||
|
||||
- 配置 OpenAI 兼容接口:Base URL、API Key、Model 等。
|
||||
- 设置持久化到 `./data/ai/settings.json`。
|
||||
- 启动或保存时通过同步机制推送到 FastAPI(`llm_config`),使报告生成与决策走大模型而非纯模板。
|
||||
|
||||
#### 3.2.10 YOLO 权重管理(仅管理员)
|
||||
|
||||
- 查看当前权重、模式(real / demo)、推理统计。
|
||||
- 上传 / 激活权重文件,支撑影像检测能力切换。
|
||||
|
||||
#### 3.2.11 用户管理(仅管理员)
|
||||
|
||||
- 用户 CRUD:账号、密码、姓名、角色、科室、电话、邮箱、启用状态。
|
||||
- 头像设置;禁止停用当前登录账号等业务保护。
|
||||
- 前端菜单与路由按角色隐藏/拦截;后端接口权限校验。
|
||||
|
||||
### 3.3 前端页面与路由对照
|
||||
|
||||
| 路由 | 页面 | 权限 |
|
||||
|------|------|------|
|
||||
| `/login` | 登录 | 公开 |
|
||||
| `/dashboard` | 仪表盘 | 已登录 |
|
||||
| `/patients` | 患者管理 | 已登录 |
|
||||
| `/imaging` | 影像诊断 | 已登录 |
|
||||
| `/emrs` | 电子病历 | 已登录 |
|
||||
| `/appointments` | 预约挂号 | 已登录 |
|
||||
| `/ai-assistant` | AI 助手 | 已登录 |
|
||||
| `/ai-knowledge` | 知识库 | 已登录 |
|
||||
| `/ai-settings` | AI 配置 | ADMIN |
|
||||
| `/ai-yolo` | YOLO 权重 | ADMIN |
|
||||
| `/users` | 用户管理 | ADMIN |
|
||||
| `/403` | 无权访问 | 已登录 |
|
||||
|
||||
### 3.4 与早期版本的能力对比
|
||||
|
||||
| 维度 | 早期(PROJECT_REPORT 描述) | 当前实现 |
|
||||
|------|------------------------------|----------|
|
||||
| 前端 | Thymeleaf + Bootstrap | Vue 3 + Element Plus + ECharts |
|
||||
| 安全 | HttpSession | Spring Security + JWT |
|
||||
| AI 影像 | 规则/随机模拟 | FastAPI + YOLO 实检 + 报告(LLM 或模板) |
|
||||
| 决策 | 关键字规则 | RAG + 可选 LLM,失败回退模板 |
|
||||
| 业务广度 | 患者 / 影像 / 病历 | 增加预约、仪表盘增强、知识库、AI 配置、YOLO 管理 |
|
||||
| 数据库 | 依赖 MySQL | 默认 H2,可选 MySQL profile |
|
||||
|
||||
---
|
||||
|
||||
## 四、技术栈
|
||||
|
||||
### 4.1 总体一览
|
||||
|
||||
| 层级 | 技术 | 版本(项目实际) | 用途 |
|
||||
|------|------|------------------|------|
|
||||
| 前端框架 | Vue | 3.5.10 | SPA |
|
||||
| 构建工具 | Vite | 5.4.8 | 开发与打包 |
|
||||
| UI | Element Plus | 2.8.4 | 组件库 |
|
||||
| 状态 | Pinia | 2.2.4 | 用户会话等 |
|
||||
| 路由 | Vue Router | 4.4.5 | 前端路由与守卫 |
|
||||
| HTTP | Axios | 1.7.7 | 调用 `/api` |
|
||||
| 图表 | ECharts + vue-echarts | 5.5.1 / 7.0.3 | 仪表盘 |
|
||||
| 文档渲染 | marked + DOMPurify | 18.x / 3.x | AI 对话 Markdown |
|
||||
| 业务后端 | Spring Boot | 3.3.4 | REST / 安全 / JPA |
|
||||
| 语言 | Java | 17 | 后端 |
|
||||
| 安全 | Spring Security + jjwt | 0.12.6 | JWT |
|
||||
| ORM | Spring Data JPA / Hibernate | 随 Boot 3.3 | 持久化 |
|
||||
| 数据库 | H2(默认)/ MySQL(可选) | — | 业务数据 |
|
||||
| 工具 | Lombok | — | 实体简化 |
|
||||
| 构建 | Maven(含 mvnw) | — | 后端构建 |
|
||||
| AI 服务 | FastAPI + Uvicorn | ≥0.110 / ≥0.27 | AI 微服务 |
|
||||
| 视觉 | Ultralytics YOLO + OpenCV | — | 检测与标注 |
|
||||
| 预处理 | OpenCV / 可选 MONAI | — | 影像预处理 |
|
||||
| LLM / RAG | LangChain 生态 + httpx | ≥0.2 | 报告、决策、检索 |
|
||||
| 大模型 | DeepSeek 等 OpenAI 兼容 API | 可配置 | 文本生成 |
|
||||
|
||||
### 4.2 前端技术细节
|
||||
|
||||
- **工程**:`frontend/`,`type: module`,Vite 开发服务器默认 **5173**。
|
||||
- **代理**:开发态将 `/api` 代理到 `http://localhost:8080`。
|
||||
- **自动导入**:`unplugin-auto-import`、`unplugin-vue-components` 简化 Element Plus 使用。
|
||||
- **布局**:`BasicLayout` 侧边栏 + 顶栏 + 主内容滚动;Dialog/Drawer 普遍 `append-to-body`,避免被 `overflow` 裁切。
|
||||
- **生产构建**:`npm run build` 产物可置于 Nginx 或由 Spring 静态资源 / SPA 回退托管。
|
||||
|
||||
### 4.3 业务后端技术细节
|
||||
|
||||
- **工程**:`smart-hospital/`(Maven 子工程)。
|
||||
- **端口**:`8080`。
|
||||
- **包结构**:`controller` / `service` / `repository` / `model` / `dto` / `security` / `config` / `common`。
|
||||
- **统一响应**:`Result<T>`,`code == 0` 表示成功;全局异常处理业务码与校验错误。
|
||||
- **异步诊断**:`AsyncConfig` 线程池 + `AIDiagnosisService` 异步分析。
|
||||
- **文件存储**:影像与头像本地目录 `./uploads/`;AI 设置与聊天 `./data/ai/`。
|
||||
- **AI 客户端**:`AiServiceClient` 调用 FastAPI;失败时服务内降级逻辑保证可用性。
|
||||
- **LLM 同步**:`AiLlmSyncRunner` / `AiSettingsService` 将管理端配置同步到 AI 微服务。
|
||||
|
||||
### 4.4 AI 微服务技术细节
|
||||
|
||||
- **工程**:`ai-service/`,Python 3.10+(开发环境实测 3.12 可用)。
|
||||
- **端口**:`8001`。
|
||||
- **主要路由模块**:
|
||||
- `/health` — 健康与能力探测
|
||||
- `/imaging/analyze` — YOLO 检测 + 报告
|
||||
- `/report/imaging`、`/report/decision` — 报告与决策
|
||||
- `/rag/*` — 知识检索与写入
|
||||
- `/yolo/*` — 权重与统计
|
||||
- `/llm-config` — 运行时 LLM 配置
|
||||
- **报告策略**:
|
||||
- 启用 LLM:结构化 JSON(所见 / 印象 / 建议),再由模板拼接分段 `full_report`
|
||||
- 未启用或失败:模板 / 规则文案
|
||||
- **内置知识**:`app/knowledge/` 下高血压、糖尿病、肺炎、肺结节、骨折等 Markdown 片段。
|
||||
- **权重目录**:`data/weights/`(如 `best.pt`、`yolov8n.pt` 等演示权重)。
|
||||
|
||||
### 4.5 数据库与配置
|
||||
|
||||
| 项 | 默认(H2) | MySQL Profile |
|
||||
|----|------------|---------------|
|
||||
| 连接 | `jdbc:h2:mem:smart_hospital` | `application-mysql.yml` |
|
||||
| 控制台 | `/h2-console`(sa / 空密码) | — |
|
||||
| DDL | `hibernate.ddl-auto: update` | 同左或按环境调整 |
|
||||
| 种子数据 | `DataInitializer` 自动写入 | 同左 |
|
||||
|
||||
其他关键配置(`application.yml`):
|
||||
|
||||
- `jwt.*`:密钥、过期时间、Header 前缀
|
||||
- `cors.allowed-origins`:含 `http://localhost:5173`
|
||||
- `ai.service.base-url`:`http://127.0.0.1:8001`
|
||||
- `imaging.storage.path`:`./uploads/images`
|
||||
- 上传限制:业务 multipart 最大约 500MB(兼容 YOLO 权重上传)
|
||||
|
||||
---
|
||||
|
||||
## 五、系统架构
|
||||
|
||||
### 5.1 逻辑架构
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ 浏览器 Vue SPA │
|
||||
│ localhost:5173 │
|
||||
└──────────┬───────────┘
|
||||
│ /api (Vite 代理)
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Spring Boot 业务端 │
|
||||
│ localhost:8080 │
|
||||
│ JWT / JPA / 文件 │
|
||||
└──────────┬───────────┘
|
||||
│ HTTP(可选)
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌─────────────────┐
|
||||
│ H2 / MySQL │ │ FastAPI AI 服务 │
|
||||
│ 业务库 │ │ localhost:8001 │
|
||||
└────────────────┘ │ YOLO / RAG / LLM│
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
本地权重 pt 知识 Markdown 外部 LLM API
|
||||
```
|
||||
|
||||
### 5.2 调用关系原则
|
||||
|
||||
1. **浏览器只访问业务后端**(开发时经 Vite 代理),不直接依赖 AI 端口(管理端部分 YOLO 能力可经 Spring 转发)。
|
||||
2. **AI 能力集中在 FastAPI**;Spring 负责鉴权、落库、任务状态与降级。
|
||||
3. **配置单向同步**:管理端保存 LLM 配置 → 持久化 JSON → 同步 AI 运行时。
|
||||
4. **失败可降级**:AI 超时或宕机时,诊断与决策仍可返回规则/模板结果。
|
||||
|
||||
### 5.3 部署形态(实训推荐)
|
||||
|
||||
| 进程 | 命令摘要 | 端口 |
|
||||
|------|----------|------|
|
||||
| AI | `uvicorn app.main:app --host 0.0.0.0 --port 8001` | 8001 |
|
||||
| 业务 | `mvnw spring-boot:run` 或 `java -jar …jar` | 8080 |
|
||||
| 前端 | `npm run dev` | 5173 |
|
||||
|
||||
生产可仅保留 AI + 业务 jar,前端 `build` 后由 Nginx 或 Spring 静态托管。
|
||||
|
||||
---
|
||||
|
||||
## 六、目录与模块结构
|
||||
|
||||
```
|
||||
smart-hospital/ # 仓库根
|
||||
├── README.md # 启动与接口速览
|
||||
├── PROJECT_REPORT.md # 早期探索报告(历史参考)
|
||||
├── 项目详细文档.md # 本文件
|
||||
├── smart-hospital.sql # MySQL 初始化脚本(可选)
|
||||
├── data/ai/ # 根目录侧 AI 文件(若存在)
|
||||
├── uploads/ # 根目录侧上传样例(若存在)
|
||||
│
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ └── src/
|
||||
│ ├── api/ # 按域划分的 HTTP 封装
|
||||
│ ├── layouts/ # BasicLayout
|
||||
│ ├── router/ # 路由与守卫
|
||||
│ ├── stores/ # Pinia(用户)
|
||||
│ ├── utils/ # 标签映射、Markdown 等
|
||||
│ └── views/ # 各业务页面
|
||||
│
|
||||
├── smart-hospital/ # Spring Boot 业务后端
|
||||
│ ├── pom.xml
|
||||
│ ├── mvnw / mvnw.cmd
|
||||
│ ├── data/ai/ # settings.json、chat-history.json
|
||||
│ ├── uploads/ # 影像、头像、标注图
|
||||
│ └── src/main/
|
||||
│ ├── java/com/hospital/ # 应用代码
|
||||
│ └── resources/
|
||||
│ ├── application.yml
|
||||
│ ├── application-mysql.yml
|
||||
│ └── static/ # 可选内嵌前端构建产物
|
||||
│
|
||||
└── ai-service/ # FastAPI AI 微服务
|
||||
├── requirements.txt
|
||||
├── README.md
|
||||
├── data/weights/ # YOLO 权重
|
||||
├── samples/ # 示例影像
|
||||
└── app/
|
||||
├── main.py
|
||||
├── api/ # imaging / report / rag / yolo / llm_config
|
||||
├── services/ # yolo、report、rag、llm
|
||||
├── schemas/
|
||||
└── knowledge/ # RAG 文档片段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、核心业务流程
|
||||
|
||||
### 7.1 AI 影像诊断流程
|
||||
|
||||
```
|
||||
医生/技师 新建影像记录(上传图片)
|
||||
│
|
||||
▼
|
||||
状态 = PENDING
|
||||
│
|
||||
│ 点击「AI 诊断」
|
||||
▼
|
||||
状态 = ANALYZING ──异步──► Spring AIDiagnosisService
|
||||
│ │
|
||||
│ ▼
|
||||
│ 调用 FastAPI /imaging/analyze
|
||||
│ │
|
||||
│ ┌─────────┴─────────┐
|
||||
│ ▼ ▼
|
||||
│ YOLO 检测框 报告生成
|
||||
│ 绘制标注图 (LLM 或模板)
|
||||
│ │ │
|
||||
│ └─────────┬─────────┘
|
||||
│ ▼
|
||||
│ 写 AIDiagnosisResult
|
||||
│ 更新 ImagingRecord
|
||||
▼
|
||||
状态 = COMPLETED / ERROR
|
||||
│
|
||||
▼
|
||||
前端轮询 / 打开报告弹窗
|
||||
(所见 · 印象 · 建议 · 检测明细 · 完整报告)
|
||||
```
|
||||
|
||||
### 7.2 电子病历辅助决策流程
|
||||
|
||||
```
|
||||
医生填写并保存病历(含诊断等字段)
|
||||
│
|
||||
▼
|
||||
Spring DecisionSupportService
|
||||
│
|
||||
▼
|
||||
FastAPI /report/decision
|
||||
│
|
||||
├─ RAG 检索 knowledge + 业务知识库
|
||||
├─ 可选 LLM 生成结构化建议
|
||||
└─ 失败则按诊断关键词走模板
|
||||
│
|
||||
▼
|
||||
返回治疗 / 用药 / 护理 / 随访 / 风险 / 冲突 / 来源
|
||||
│
|
||||
▼
|
||||
前端 Dialog 分节展示
|
||||
```
|
||||
|
||||
### 7.3 LLM 配置同步
|
||||
|
||||
```
|
||||
管理员在「AI 配置」保存
|
||||
│
|
||||
▼
|
||||
写入 ./data/ai/settings.json
|
||||
│
|
||||
▼
|
||||
调用 AI 服务 llm_config 接口
|
||||
│
|
||||
▼
|
||||
FastAPI 运行时启用/更新 LLM
|
||||
(报告与决策从模板切到大模型)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、数据模型概要
|
||||
|
||||
### 8.1 主要业务实体
|
||||
|
||||
| 实体 | 表名 | 要点 |
|
||||
|------|------|------|
|
||||
| User | users | 角色 ADMIN/DOCTOR/RADIOLOGIST,BCrypt 密码,科室等 |
|
||||
| Patient | patients | 姓名、性别、年龄、证件、联系方式、既往史 |
|
||||
| ImagingRecord | imaging_records | 患者、医生、检查类型、部位、图像 URL、状态、AI 摘要字段 |
|
||||
| AIDiagnosisResult | ai_diagnosis_results | 诊断文本、置信度、所见、建议、检测 JSON、标注图、完整报告、引擎与是否降级 |
|
||||
| ElectronicMedicalRecord | electronic_medical_records | 主诉至随访全字段 + 关联患者/医生 |
|
||||
| DecisionSupportRecord 等 | 决策相关表 | 辅助决策落库(按实现) |
|
||||
| Appointment | appointments | 预约日、科室、事由、状态机 |
|
||||
| AiKnowledgeDoc | 知识文档表 | 标题、分类、正文、启用 |
|
||||
| AiSettings / 聊天 | 文件为主 | `settings.json`、`chat-history.json` |
|
||||
|
||||
### 8.2 影像状态机
|
||||
|
||||
| 状态 | 含义 |
|
||||
|------|------|
|
||||
| PENDING | 已登记,待诊断 |
|
||||
| ANALYZING | 诊断进行中 |
|
||||
| COMPLETED | 成功,可查看报告 |
|
||||
| ERROR | 失败 |
|
||||
|
||||
### 8.3 检查类型
|
||||
|
||||
`X_RAY` · `CT` · `MRI` · `ULTRASOUND`
|
||||
|
||||
### 8.4 关系简图
|
||||
|
||||
```
|
||||
User ──┬──< ImagingRecord >── Patient
|
||||
│ │
|
||||
│ └── AIDiagnosisResult
|
||||
│
|
||||
├──< ElectronicMedicalRecord >── Patient
|
||||
│ │
|
||||
│ └── Decision / Suggestions(按实现落库)
|
||||
│
|
||||
└──< Appointment >── Patient
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、接口与权限
|
||||
|
||||
### 9.1 统一响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
`code != 0` 时,前端 Axios 拦截器统一 `ElMessage` 提示。
|
||||
|
||||
### 9.2 业务 REST 一览(节选)
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/auth/login` | 登录 | 公开 |
|
||||
| GET | `/api/auth/me` | 当前用户 | 已登录 |
|
||||
| POST | `/api/auth/change-password` | 修改密码 | 已登录 |
|
||||
| GET | `/api/stats/overview` | 统计概览 | 已登录 |
|
||||
| * | `/api/patients/**` | 患者 CRUD / profile | 已登录 |
|
||||
| * | `/api/imaging/**` | 影像 CRUD / 上传 | 已登录 |
|
||||
| POST | `/api/ai-diagnosis/analyze/{id}` | 触发诊断 | 已登录 |
|
||||
| GET | `/api/ai-diagnosis/result/{id}` | 诊断结果 | 已登录 |
|
||||
| * | `/api/emrs/**` | 病历 CRUD | 已登录 |
|
||||
| GET | `/api/emrs/{id}/ai-suggestions` | 辅助决策 | 已登录 |
|
||||
| * | `/api/appointments/**` | 预约 CRUD / 状态 | 已登录 |
|
||||
| * | `/api/users/**` | 用户管理 | ADMIN |
|
||||
| * | AI 管理 / 聊天 / 知识库等 | 见对应 Controller | 已登录或 ADMIN |
|
||||
|
||||
### 9.3 AI 微服务接口(节选)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 健康检查 |
|
||||
| POST | `/imaging/analyze` | 检测 + 报告 |
|
||||
| POST | `/report/decision` | 病历决策 |
|
||||
| POST | `/report/imaging` | 单独报告 |
|
||||
| POST | `/rag/query` | 知识问答 |
|
||||
| * | `/yolo/*` | 权重与统计 |
|
||||
| * | `/llm-config` | 运行时 LLM 配置 |
|
||||
|
||||
完整 OpenAPI:`http://127.0.0.1:8001/docs`。
|
||||
|
||||
---
|
||||
|
||||
## 十、部署与运行
|
||||
|
||||
### 10.1 环境要求
|
||||
|
||||
| 组件 | 要求 |
|
||||
|------|------|
|
||||
| JDK | 17+ |
|
||||
| Node.js | 18+ |
|
||||
| Python | 3.10+(推荐 3.11/3.12) |
|
||||
| 可选 | MySQL 8.x;NVIDIA GPU(非必须,CPU 可跑) |
|
||||
|
||||
### 10.2 启动顺序(推荐)
|
||||
|
||||
```text
|
||||
1) ai-service :8001
|
||||
2) smart-hospital :8080
|
||||
3) frontend :5173
|
||||
```
|
||||
|
||||
#### AI 服务
|
||||
|
||||
```bash
|
||||
cd ai-service
|
||||
python -m venv .venv
|
||||
# Windows: .venv\Scripts\activate
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
# 可选:配置 .env 中 LLM_API_KEY
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
#### 业务后端
|
||||
|
||||
```bash
|
||||
cd smart-hospital
|
||||
mvnw.cmd spring-boot:run
|
||||
# 或
|
||||
java -jar target/smart-hospital-1.0.0.jar
|
||||
```
|
||||
|
||||
MySQL 模式:
|
||||
|
||||
```bash
|
||||
mvnw.cmd spring-boot:run -Dspring-boot.run.profiles=mysql
|
||||
```
|
||||
|
||||
#### 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
浏览器访问:`http://localhost:5173`。
|
||||
|
||||
### 10.3 健康检查
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端 | http://localhost:5173 |
|
||||
| 业务 API | http://localhost:8080/api/... |
|
||||
| H2 控制台 | http://localhost:8080/h2-console |
|
||||
| AI 健康 | http://127.0.0.1:8001/health |
|
||||
| AI 文档 | http://127.0.0.1:8001/docs |
|
||||
|
||||
---
|
||||
|
||||
## 十一、演示账号与推荐路径
|
||||
|
||||
### 11.1 内置账号(DataInitializer)
|
||||
|
||||
| 用户名 | 密码 | 角色 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| admin | admin123 | ADMIN | 用户管理、AI 配置、YOLO 权重 |
|
||||
| doctor1 | pass123 | DOCTOR | 临床业务主演示账号 |
|
||||
| radio1 | radio123 | RADIOLOGIST | 影像相关演示 |
|
||||
|
||||
### 11.2 推荐演示剧本
|
||||
|
||||
1. 使用 `doctor1 / pass123` 登录,浏览仪表盘 KPI 与图表。
|
||||
2. **患者管理**:查看列表 → 打开 360° 档案。
|
||||
3. **影像诊断**:新建检查并上传样例图 → AI 诊断 → 对比原图/标注图 → 阅读分段完整报告。
|
||||
4. **电子病历**:新建病历,诊断填写「高血压」或「肺炎」→ 查看 AI 建议与知识库引用。
|
||||
5. **预约挂号**:新建预约并切换状态。
|
||||
6. 切换 `admin`:进入 AI 配置(可填 DeepSeek Key)、YOLO 权重、用户管理。
|
||||
7. 对比:关闭 AI 服务后再次诊断,观察 **降级** 是否仍返回结果。
|
||||
|
||||
### 11.3 验证清单(节选)
|
||||
|
||||
- [ ] 三端均能启动,5173 可登录
|
||||
- [ ] admin 可见用户管理;doctor1 访问 `/users` 为 403
|
||||
- [ ] 患者增删改与档案抽屉正常
|
||||
- [ ] 影像 AI 状态能到 COMPLETED,报告分段清晰
|
||||
- [ ] 病历 AI 建议弹窗含多类内容
|
||||
- [ ] 预约状态可流转
|
||||
- [ ] 修改密码后需重新登录
|
||||
|
||||
---
|
||||
|
||||
## 十二、设计说明与边界
|
||||
|
||||
### 12.1 关键设计取舍
|
||||
|
||||
| 取舍 | 原因 |
|
||||
|------|------|
|
||||
| 默认 H2 | 降低实训环境门槛,开箱即演示 |
|
||||
| AI 独立进程 | 隔离 Python 视觉/LLM 依赖,避免撑爆 Java 工程 |
|
||||
| JWT 无状态 | 适配前后端分离与多端调用 |
|
||||
| LLM 可关 | 无 Key 时用模板/RAG,保证答辩可演示 |
|
||||
| 报告强制分段拼接 | 避免大模型输出「墙文本」影响阅读 |
|
||||
| Dialog append-to-body | Element Plus 2.8 默认不挂 body,易被布局 overflow 裁切 |
|
||||
|
||||
### 12.2 已知边界(非缺陷说明)
|
||||
|
||||
- 非完整 PACS/RIS/HIS 产品,无医保、收费、电子签名、CA 等模块。
|
||||
- YOLO 类别与权重为演示级,不保证临床敏感性/特异性。
|
||||
- H2 内存库重启丢失业务表数据;需持久化请改用 MySQL profile。
|
||||
- 大模型与外部 API 受网络、额度、延迟影响;超时有配置上限。
|
||||
- 早期 `PROJECT_REPORT.md` 描述的是改造前架构,**以本文档与当前代码为准**。
|
||||
|
||||
### 12.3 后续可扩展方向(建议)
|
||||
|
||||
- DICOM 解析与序列阅片
|
||||
- 报告 PDF 导出与医师电子签收工作流
|
||||
- 更细粒度的科室/数据权限与操作审计
|
||||
- 向量库(如 Chroma/FAISS)替换简易 RAG
|
||||
- Docker Compose 一键拉起三端
|
||||
- 接口自动化测试与 CI
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:技术栈版本速查表
|
||||
|
||||
| 名称 | 版本 |
|
||||
|------|------|
|
||||
| Spring Boot | 3.3.4 |
|
||||
| Java | 17 |
|
||||
| jjwt | 0.12.6 |
|
||||
| Vue | 3.5.10 |
|
||||
| Vite | 5.4.8 |
|
||||
| Element Plus | 2.8.4 |
|
||||
| Pinia | 2.2.4 |
|
||||
| Vue Router | 4.4.5 |
|
||||
| Axios | 1.7.7 |
|
||||
| ECharts | 5.5.1 |
|
||||
| FastAPI | ≥0.110 |
|
||||
| Ultralytics | requirements 中指定 |
|
||||
| LangChain | ≥0.2 |
|
||||
|
||||
## 附录 B:文档维护
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 本文档路径 | `项目详细文档.md`(仓库根目录) |
|
||||
| 快速启动 | 见 `README.md` |
|
||||
| AI 专项 | 见 `ai-service/README.md` |
|
||||
| 历史探索 | 见 `PROJECT_REPORT.md`(可能过时) |
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||