docs: update content and components across multiple files

- Refine chapter introductions in zh-cn docs for clarity and conciseness
- Update navigation links to include '/easy-vibe' prefix
- Simplify UI components (ChapterIntroduction, ContextWindowVisualizer)
- Add new agent-related demo components (AgentMemoryDemo, AgentToolUseDemo)
- Improve context compression demo with better visuals and metrics
- Adjust styling and layout across various components
This commit is contained in:
sanbuphy
2026-02-03 01:46:03 +08:00
parent ad95658a11
commit e5b1c6cc88
31 changed files with 11651 additions and 2156 deletions
@@ -0,0 +1,545 @@
<!--
CFGScaleDemo.vue
CFG Scale 演示组件
用途
展示 Classifier-Free Guidance (CFG) Scale 如何影响生成结果帮助用户理解提示词遵循度的概念
交互功能
- CFG Scale 滑动调节
- 实时对比不同 CFG 值的效果
- 可视化 CFG 对图像的影响
-->
<template>
<div class="cfg-scale-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><ScaleToOriginal /></el-icon>
<span> CFG Scale提示词遵循度</span>
</div>
</template>
<div class="demo-content">
<!-- CFG 控制 -->
<div class="cfg-control">
<div class="cfg-slider-section">
<div class="cfg-label">
<span>CFG Scale</span>
<el-tag type="primary" effect="dark" size="large">{{ cfgScale }}</el-tag>
</div>
<el-slider
v-model="cfgScale"
:min="1"
:max="15"
:step="0.5"
show-stops
:marks="{
1: '1\n(自由创作)',
7: '7\n(平衡)',
15: '15\n(严格遵循)'
}"
/>
</div>
<div class="cfg-presets">
<el-button
v-for="preset in cfgPresets"
:key="preset.value"
:type="cfgScale === preset.value ? 'primary' : ''"
size="small"
@click="cfgScale = preset.value"
>
{{ preset.label }}
</el-button>
</div>
</div>
<!-- 对比展示 -->
<div class="comparison-display">
<div class="comparison-item">
<div class="item-label">
<el-tag type="info">无条件生成</el-tag>
<span class="cfg-value">CFG = 1</span>
</div>
<canvas
ref="uncondCanvas"
width="200"
height="200"
class="comparison-canvas"
/>
<div class="item-desc">忽略提示词自由发挥</div>
</div>
<div class="comparison-arrow">
<el-icon :size="32"><ArrowRight /></el-icon>
<div class="guidance-formula">
<div class="formula">输出 = 无条件 + CFG × (有条件 - 无条件)</div>
<div class="formula-desc">CFG 越大提示词影响越强</div>
</div>
</div>
<div class="comparison-item">
<div class="item-label">
<el-tag type="success">当前设置</el-tag>
<span class="cfg-value">CFG = {{ cfgScale }}</span>
</div>
<canvas
ref="currentCanvas"
width="200"
height="200"
class="comparison-canvas"
/>
<div class="item-desc">{{ getCfgDescription() }}</div>
</div>
</div>
<!-- CFG 效果展示 -->
<div class="cfg-effects">
<div class="effects-title">不同 CFG 值的效果对比</div>
<div class="effects-grid">
<div
v-for="effect in cfgEffects"
:key="effect.value"
class="effect-item"
:class="{ active: cfgScale === effect.value }"
@click="cfgScale = effect.value"
>
<canvas
:ref="el => setEffectCanvas(el, effect.value)"
width="120"
height="120"
class="effect-canvas"
/>
<div class="effect-label">CFG {{ effect.value }}</div>
<div class="effect-desc">{{ effect.desc }}</div>
</div>
</div>
</div>
<!-- 推荐设置 -->
<div class="recommendations">
<div class="rec-title">🎯 推荐设置</div>
<div class="rec-grid">
<div class="rec-item">
<div class="rec-scenario">创意探索</div>
<div class="rec-value">CFG 3-5</div>
<div class="rec-desc"> AI 更多自由适合艺术探索</div>
</div>
<div class="rec-item">
<div class="rec-scenario">平衡模式</div>
<div class="rec-value">CFG 7-9</div>
<div class="rec-desc">大多数场景的最佳选择</div>
</div>
<div class="rec-item">
<div class="rec-scenario">精确控制</div>
<div class="rec-value">CFG 12-15</div>
<div class="rec-desc">严格遵循提示词但可能过饱和</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>CFG Scale 原理</strong>
CFG (Classifier-Free Guidance) 控制生成结果对提示词的遵循程度值越高图像越符合提示词描述但过高会导致图像过饱和或失真
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ScaleToOriginal, ArrowRight } from '@element-plus/icons-vue'
const cfgScale = ref(7.5)
const uncondCanvas = ref(null)
const currentCanvas = ref(null)
const effectCanvases = ref({})
const cfgPresets = [
{ label: '自由 (3)', value: 3 },
{ label: '平衡 (7)', value: 7 },
{ label: '严格 (12)', value: 12 }
]
const cfgEffects = [
{ value: 1, desc: '完全自由' },
{ value: 3, desc: '创意优先' },
{ value: 5, desc: '轻度引导' },
{ value: 7, desc: '平衡' },
{ value: 9, desc: '严格遵循' },
{ value: 12, desc: '非常严格' },
{ value: 15, desc: '过度饱和' }
]
const setEffectCanvas = (el, value) => {
if (el) {
effectCanvases.value[value] = el
}
}
// 绘制目标图像
const drawTargetImage = (ctx, width, height, cfgValue) => {
// 基础图像(提示词:一只蓝色的猫)
const baseColor = { r: 100, g: 150, b: 200 }
// 根据 CFG 值调整颜色饱和度
const saturationBoost = Math.min((cfgValue - 1) / 7, 1.5)
const color = {
r: Math.min(255, baseColor.r + saturationBoost * 50),
g: Math.max(0, baseColor.g - saturationBoost * 30),
b: Math.min(255, baseColor.b + saturationBoost * 30)
}
// 背景
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, width, height)
// 猫的形状
ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`
// 身体
ctx.beginPath()
ctx.ellipse(width / 2, height * 0.65, width * 0.25, height * 0.2, 0, 0, Math.PI * 2)
ctx.fill()
// 头
ctx.beginPath()
ctx.arc(width / 2, height * 0.4, width * 0.18, 0, Math.PI * 2)
ctx.fill()
// 耳朵
ctx.beginPath()
ctx.moveTo(width * 0.35, height * 0.3)
ctx.lineTo(width * 0.3, height * 0.15)
ctx.lineTo(width * 0.42, height * 0.25)
ctx.fill()
ctx.beginPath()
ctx.moveTo(width * 0.65, height * 0.3)
ctx.lineTo(width * 0.7, height * 0.15)
ctx.lineTo(width * 0.58, height * 0.25)
ctx.fill()
// 眼睛
ctx.fillStyle = '#fff'
ctx.beginPath()
ctx.ellipse(width * 0.45, height * 0.38, width * 0.05, height * 0.04, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(width * 0.55, height * 0.38, width * 0.05, height * 0.04, 0, 0, Math.PI * 2)
ctx.fill()
// 瞳孔
ctx.fillStyle = '#000'
ctx.beginPath()
ctx.arc(width * 0.45, height * 0.38, width * 0.025, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(width * 0.55, height * 0.38, width * 0.025, 0, Math.PI * 2)
ctx.fill()
// 添加噪声(模拟低 CFG 的自由度)
if (cfgValue < 5) {
const imageData = ctx.getImageData(0, 0, width, height)
const noiseAmount = (5 - cfgValue) / 5 * 30
for (let i = 0; i < imageData.data.length; i += 4) {
const noise = (Math.random() - 0.5) * noiseAmount
imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + noise))
imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + noise))
imageData.data[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] + noise))
}
ctx.putImageData(imageData, 0, 0)
}
// 添加过饱和效果(高 CFG
if (cfgValue > 10) {
const imageData = ctx.getImageData(0, 0, width, height)
const oversaturation = (cfgValue - 10) / 5
for (let i = 0; i < imageData.data.length; i += 4) {
// 增强对比度
const avg = (imageData.data[i] + imageData.data[i + 1] + imageData.data[i + 2]) / 3
imageData.data[i] = Math.min(255, imageData.data[i] + (imageData.data[i] - avg) * oversaturation)
imageData.data[i + 1] = Math.min(255, imageData.data[i + 1] + (imageData.data[i + 1] - avg) * oversaturation)
imageData.data[i + 2] = Math.min(255, imageData.data[i + 2] + (imageData.data[i + 2] - avg) * oversaturation)
}
ctx.putImageData(imageData, 0, 0)
}
}
const getCfgDescription = () => {
if (cfgScale.value <= 3) return '自由创作,AI 有更多发挥空间'
if (cfgScale.value <= 7) return '平衡模式,兼顾创意和遵循'
if (cfgScale.value <= 10) return '严格遵循提示词'
return '过度控制,可能导致图像失真'
}
const updateDisplay = () => {
// 更新无条件生成
if (uncondCanvas.value) {
const ctx = uncondCanvas.value.getContext('2d')
drawTargetImage(ctx, 200, 200, 1)
}
// 更新当前设置
if (currentCanvas.value) {
const ctx = currentCanvas.value.getContext('2d')
drawTargetImage(ctx, 200, 200, cfgScale.value)
}
// 更新效果网格
cfgEffects.forEach(effect => {
const canvas = effectCanvases.value[effect.value]
if (canvas) {
const ctx = canvas.getContext('2d')
drawTargetImage(ctx, 120, 120, effect.value)
}
})
}
onMounted(updateDisplay)
watch(cfgScale, updateDisplay)
</script>
<style scoped>
.cfg-scale-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.cfg-control {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.cfg-slider-section {
margin-bottom: 16px;
}
.cfg-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.cfg-label span {
font-weight: 500;
}
.cfg-presets {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.comparison-display {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.comparison-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.item-label {
display: flex;
align-items: center;
gap: 8px;
}
.cfg-value {
font-weight: 600;
color: var(--vp-c-text-2);
}
.comparison-canvas {
width: 180px;
height: 180px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.item-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
text-align: center;
}
.comparison-arrow {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vp-c-brand);
}
.guidance-formula {
text-align: center;
max-width: 200px;
}
.formula {
font-size: 0.75rem;
font-family: var(--vp-font-family-mono);
background: var(--vp-c-bg);
padding: 8px;
border-radius: 4px;
margin-bottom: 4px;
}
.formula-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.cfg-effects {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.effects-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.effects-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 16px;
}
.effect-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--vp-c-bg);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.effect-item:hover {
border-color: var(--vp-c-brand);
}
.effect-item.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.effect-canvas {
width: 100px;
height: 100px;
border-radius: 6px;
}
.effect-label {
font-weight: 500;
font-size: 0.875rem;
}
.effect-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
text-align: center;
}
.recommendations {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.rec-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.rec-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.rec-item {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.rec-scenario {
font-weight: 500;
margin-bottom: 8px;
}
.rec-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--vp-c-brand);
margin-bottom: 8px;
}
.rec-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.comparison-display {
flex-direction: column;
}
.comparison-arrow {
transform: rotate(90deg);
margin: 8px 0;
}
}
</style>
@@ -0,0 +1,727 @@
<!--
ControlNetDemo.vue
ControlNet 控制网络演示组件
用途
展示 ControlNet 如何精确控制图像生成包括姿态边缘深度等控制方式
交互功能
- 不同控制类型切换
- 控制强度调节
- 可视化控制信号
- 对比有无 ControlNet 的效果
-->
<template>
<div class="controlnet-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Pointer /></el-icon>
<span>🎮 ControlNet精确控制</span>
</div>
</template>
<div class="demo-content">
<!-- 控制类型选择 -->
<div class="control-types">
<div
v-for="control in controlTypes"
:key="control.id"
class="control-card"
:class="{ active: selectedControl === control.id }"
@click="selectedControl = control.id"
>
<div class="control-icon">{{ control.icon }}</div>
<div class="control-name">{{ control.name }}</div>
<div class="control-desc">{{ control.description }}</div>
</div>
</div>
<!-- 可视化流程 -->
<div class="workflow-viz">
<div class="workflow-step">
<div class="step-label">输入图像</div>
<canvas
ref="inputCanvas"
width="200"
height="200"
class="workflow-canvas"
/>
</div>
<div class="workflow-arrow">
<el-icon :size="24"><ArrowRight /></el-icon>
<div class="arrow-label">提取</div>
</div>
<div class="workflow-step">
<div class="step-label">控制信号</div>
<canvas
ref="controlCanvas"
width="200"
height="200"
class="workflow-canvas control-signal"
/>
</div>
<div class="workflow-arrow">
<el-icon :size="24"><ArrowRight /></el-icon>
<div class="arrow-label">+ 提示词</div>
</div>
<div class="workflow-step">
<div class="step-label">生成结果</div>
<canvas
ref="outputCanvas"
width="200"
height="200"
class="workflow-canvas"
/>
</div>
</div>
<!-- 控制强度 -->
<div class="strength-control">
<div class="strength-header">
<span>控制强度 (Control Strength)</span>
<el-tag type="primary" effect="dark">{{ controlStrength }}</el-tag>
</div>
<el-slider
v-model="controlStrength"
:min="0"
:max="2"
:step="0.1"
show-stops
:marks="{
0: '无控制',
1: '平衡',
2: '强控制'
}"
/>
<div class="strength-desc">
{{ getStrengthDescription() }}
</div>
</div>
<!-- 对比展示 -->
<div class="comparison-section">
<div class="comparison-title">对比有无 ControlNet</div>
<div class="comparison-grid">
<div class="comparison-item">
<div class="item-label">
<el-tag type="info">仅文本生成</el-tag>
</div>
<canvas
ref="textOnlyCanvas"
width="180"
height="180"
class="comparison-canvas"
/>
<div class="item-desc">姿态随机不可控</div>
</div>
<div class="comparison-item">
<div class="item-label">
<el-tag type="success">ControlNet 控制</el-tag>
</div>
<canvas
ref="controlNetCanvas"
width="180"
height="180"
class="comparison-canvas"
/>
<div class="item-desc">姿态精确匹配输入</div>
</div>
</div>
</div>
<!-- 应用场景 -->
<div class="use-cases">
<div class="use-cases-title">🎯 典型应用场景</div>
<div class="use-cases-grid">
<div
v-for="useCase in useCases"
:key="useCase.title"
class="use-case-card"
>
<div class="use-case-icon">{{ useCase.icon }}</div>
<div class="use-case-title">{{ useCase.title }}</div>
<div class="use-case-desc">{{ useCase.description }}</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>ControlNet 原理</strong>
ControlNet 是一个附加在扩散模型上的神经网络它学习从输入图像中提取特定的结构信息如姿态边缘并用这些信息引导生成过程实现精确控制
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { Pointer, ArrowRight } from '@element-plus/icons-vue'
const selectedControl = ref('pose')
const controlStrength = ref(1.0)
const inputCanvas = ref(null)
const controlCanvas = ref(null)
const outputCanvas = ref(null)
const textOnlyCanvas = ref(null)
const controlNetCanvas = ref(null)
const controlTypes = [
{
id: 'pose',
name: 'OpenPose',
icon: '🕺',
description: '姿态控制,提取人体骨骼关键点'
},
{
id: 'canny',
name: 'Canny',
icon: '✏️',
description: '边缘检测,提取图像轮廓'
},
{
id: 'depth',
name: 'Depth',
icon: '📐',
description: '深度估计,控制空间结构'
},
{
id: 'scribble',
name: 'Scribble',
icon: '🎨',
description: '涂鸦控制,手绘引导生成'
},
{
id: 'segmentation',
name: 'Segmentation',
icon: '🧩',
description: '语义分割,控制物体布局'
}
]
const useCases = [
{
icon: '👗',
title: '虚拟试衣',
description: '保持人物姿态,更换服装款式'
},
{
icon: '🏠',
title: '室内设计',
description: '基于房间结构,生成不同装修风格'
},
{
icon: '🎭',
title: '角色一致性',
description: '保持角色姿态,改变服装或场景'
},
{
icon: '📐',
title: '产品展示',
description: '固定产品角度,变换背景和光照'
}
]
const getStrengthDescription = () => {
if (controlStrength.value < 0.5) {
return '控制较弱,生成结果更自由,但可能偏离预期结构'
} else if (controlStrength.value < 1.5) {
return '平衡模式,在遵循控制和保持创意之间取得平衡'
} else {
return '强控制模式,严格遵循输入结构,但可能牺牲一些图像质量'
}
}
// 绘制姿态骨架
const drawPoseSkeleton = (ctx, width, height, isControl = false) => {
ctx.clearRect(0, 0, width, height)
if (isControl) {
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#0f0'
ctx.fillStyle = '#0f0'
} else {
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#333'
ctx.fillStyle = '#333'
}
ctx.lineWidth = isControl ? 3 : 2
// 头部
ctx.beginPath()
ctx.arc(width * 0.5, height * 0.15, width * 0.08, 0, Math.PI * 2)
ctx.stroke()
// 身体
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.23)
ctx.lineTo(width * 0.5, height * 0.5)
ctx.stroke()
// 左臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.3)
ctx.lineTo(width * 0.25, height * 0.4)
ctx.stroke()
// 右臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.3)
ctx.lineTo(width * 0.75, height * 0.35)
ctx.stroke()
// 左腿
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.5)
ctx.lineTo(width * 0.35, height * 0.8)
ctx.stroke()
// 右腿
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.5)
ctx.lineTo(width * 0.65, height * 0.75)
ctx.stroke()
// 关节点
const joints = [
[0.5, 0.23], [0.5, 0.3], [0.5, 0.5],
[0.25, 0.4], [0.75, 0.35],
[0.35, 0.8], [0.65, 0.75]
]
joints.forEach(([x, y]) => {
ctx.beginPath()
ctx.arc(width * x, height * y, isControl ? 4 : 3, 0, Math.PI * 2)
ctx.fill()
})
}
// 绘制边缘检测
const drawCannyEdges = (ctx, width, height) => {
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
// 绘制简单的几何形状边缘
ctx.beginPath()
ctx.moveTo(width * 0.2, height * 0.2)
ctx.lineTo(width * 0.8, height * 0.2)
ctx.lineTo(width * 0.8, height * 0.8)
ctx.lineTo(width * 0.2, height * 0.8)
ctx.closePath()
ctx.stroke()
// 内部细节
ctx.beginPath()
ctx.arc(width * 0.5, height * 0.5, width * 0.2, 0, Math.PI * 2)
ctx.stroke()
}
// 绘制深度图
const drawDepthMap = (ctx, width, height) => {
// 创建深度渐变
const gradient = ctx.createRadialGradient(
width * 0.5, height * 0.5, 0,
width * 0.5, height * 0.5, width * 0.5
)
gradient.addColorStop(0, '#fff')
gradient.addColorStop(0.5, '#888')
gradient.addColorStop(1, '#000')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, width, height)
}
// 绘制涂鸦
const drawScribble = (ctx, width, height) => {
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#000'
ctx.lineWidth = 3
// 随机涂鸦线条
ctx.beginPath()
for (let i = 0; i < 5; i++) {
ctx.moveTo(Math.random() * width, Math.random() * height)
ctx.lineTo(Math.random() * width, Math.random() * height)
}
ctx.stroke()
}
// 绘制语义分割
const drawSegmentation = (ctx, width, height) => {
// 天空
ctx.fillStyle = '#87CEEB'
ctx.fillRect(0, 0, width, height * 0.4)
// 地面
ctx.fillStyle = '#8B4513'
ctx.fillRect(0, height * 0.6, width, height * 0.4)
// 建筑
ctx.fillStyle = '#808080'
ctx.fillRect(width * 0.3, height * 0.2, width * 0.4, height * 0.5)
// 树木
ctx.fillStyle = '#228B22'
ctx.beginPath()
ctx.arc(width * 0.15, height * 0.5, width * 0.1, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(width * 0.85, height * 0.5, width * 0.1, 0, Math.PI * 2)
ctx.fill()
}
// 绘制生成结果
const drawOutput = (ctx, width, height, withControl = true) => {
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, width, height)
// 根据控制类型绘制不同的输出
if (selectedControl.value === 'pose') {
// 绘制一个人物,姿态与骨架匹配
const strength = withControl ? controlStrength.value : 0.3
// 头部
ctx.fillStyle = '#fdbcb4'
ctx.beginPath()
ctx.arc(width * 0.5, height * 0.15, width * 0.08 * (0.5 + strength * 0.5), 0, Math.PI * 2)
ctx.fill()
// 身体
ctx.fillStyle = '#4a90e2'
ctx.fillRect(
width * (0.5 - 0.08 * strength),
height * 0.23,
width * 0.16 * strength,
height * 0.27
)
// 简单的肢体
ctx.strokeStyle = '#fdbcb4'
ctx.lineWidth = 8 * strength
// 左臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.3)
ctx.lineTo(width * (0.25 + (0.5 - strength) * 0.3), height * 0.4)
ctx.stroke()
// 右臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.3)
ctx.lineTo(width * (0.75 - (0.5 - strength) * 0.3), height * 0.35)
ctx.stroke()
} else if (selectedControl.value === 'canny') {
// 边缘控制效果
const strength = withControl ? controlStrength.value : 0.3
ctx.strokeStyle = '#333'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(width * 0.2, height * 0.2)
ctx.lineTo(width * (0.8 - (1 - strength) * 0.3), height * 0.2)
ctx.lineTo(width * 0.8, height * (0.8 - (1 - strength) * 0.2))
ctx.lineTo(width * (0.2 + (1 - strength) * 0.3), height * 0.8)
ctx.closePath()
ctx.stroke()
}
}
const updateDisplay = () => {
// 输入图像
if (inputCanvas.value) {
const ctx = inputCanvas.value.getContext('2d')
drawPoseSkeleton(ctx, 200, 200, false)
}
// 控制信号
if (controlCanvas.value) {
const ctx = controlCanvas.value.getContext('2d')
switch (selectedControl.value) {
case 'pose':
drawPoseSkeleton(ctx, 200, 200, true)
break
case 'canny':
drawCannyEdges(ctx, 200, 200)
break
case 'depth':
drawDepthMap(ctx, 200, 200)
break
case 'scribble':
drawScribble(ctx, 200, 200)
break
case 'segmentation':
drawSegmentation(ctx, 200, 200)
break
}
}
// 输出
if (outputCanvas.value) {
const ctx = outputCanvas.value.getContext('2d')
drawOutput(ctx, 200, 200, true)
}
// 对比
if (textOnlyCanvas.value) {
const ctx = textOnlyCanvas.value.getContext('2d')
drawOutput(ctx, 180, 180, false)
}
if (controlNetCanvas.value) {
const ctx = controlNetCanvas.value.getContext('2d')
drawOutput(ctx, 180, 180, true)
}
}
onMounted(updateDisplay)
watch([selectedControl, controlStrength], updateDisplay)
</script>
<style scoped>
.controlnet-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.control-types {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.control-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.control-card:hover {
border-color: var(--vp-c-brand);
}
.control-card.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.control-icon {
font-size: 2rem;
margin-bottom: 8px;
}
.control-name {
font-weight: 600;
margin-bottom: 4px;
}
.control-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.workflow-viz {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
padding: 20px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.workflow-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.step-label {
font-weight: 500;
color: var(--vp-c-text-2);
}
.workflow-canvas {
width: 160px;
height: 160px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.workflow-canvas.control-signal {
background: #000;
}
.workflow-arrow {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--vp-c-text-3);
}
.arrow-label {
font-size: 0.75rem;
}
.strength-control {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.strength-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.strength-desc {
margin-top: 12px;
font-size: 0.875rem;
color: var(--vp-c-text-2);
text-align: center;
}
.comparison-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.comparison-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.comparison-grid {
display: flex;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
}
.comparison-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.item-label {
font-weight: 500;
}
.comparison-canvas {
width: 150px;
height: 150px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.item-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.use-cases {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.use-cases-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.use-cases-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.use-case-card {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.use-case-icon {
font-size: 2rem;
margin-bottom: 8px;
}
.use-case-title {
font-weight: 600;
margin-bottom: 4px;
}
.use-case-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.workflow-viz {
flex-direction: column;
}
.workflow-arrow {
transform: rotate(90deg);
margin: 8px 0;
}
}
</style>
@@ -0,0 +1,569 @@
<!--
ImageGenQuickStartDemo.vue
AI 绘画快速体验组件
用途
让用户在文章开头就能体验 AI 绘画的魅力通过交互式演示理解文生图的基本概念
交互功能
- 预设提示词选择快速体验不同风格的图像生成
- 模拟生成过程展示从文本到图像的渐进过程
- 参数调节调整生成步数CFG Scale 等参数
- 对比展示对比不同参数下的生成效果
-->
<template>
<div class="image-gen-quickstart">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Picture /></el-icon>
<span>🎨 AI 绘画体验室</span>
</div>
</template>
<div class="demo-layout">
<!-- 左侧控制面板 -->
<div class="control-panel">
<div class="input-section">
<label>提示词 (Prompt)</label>
<el-input
v-model="prompt"
type="textarea"
:rows="3"
placeholder="描述你想生成的图像..."
/>
<div class="prompt-tags">
<el-tag
v-for="tag in presetPrompts"
:key="tag.label"
size="small"
class="prompt-tag"
@click="prompt = tag.prompt"
>
{{ tag.label }}
</el-tag>
</div>
</div>
<div class="params-section">
<div class="param-row">
<label>生成步数</label>
<el-slider v-model="steps" :min="10" :max="50" :step="5" show-stops />
</div>
<div class="param-row">
<label>CFG Scale (提示词遵循度)</label>
<el-slider v-model="cfgScale" :min="1" :max="15" :step="0.5" />
</div>
<div class="param-row">
<label>采样器</label>
<el-select v-model="sampler" size="small">
<el-option label="Euler" value="euler" />
<el-option label="DPM++" value="dpm" />
<el-option label="DDIM" value="ddim" />
</el-select>
</div>
</div>
<el-button
type="primary"
:loading="isGenerating"
@click="startGeneration"
class="generate-btn"
>
<el-icon><MagicStick /></el-icon>
{{ isGenerating ? '生成中...' : '开始生成' }}
</el-button>
</div>
<!-- 右侧生成展示 -->
<div class="display-panel">
<div class="canvas-container">
<canvas
ref="canvasRef"
width="400"
height="400"
class="gen-canvas"
/>
<div v-if="isGenerating" class="progress-overlay">
<el-progress
type="circle"
:percentage="progress"
:status="progress === 100 ? 'success' : ''"
/>
<p class="step-info">Step {{ currentStep }} / {{ steps }}</p>
</div>
</div>
<div class="image-info" v-if="!isGenerating && hasGenerated">
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="分辨率">512 × 512</el-descriptions-item>
<el-descriptions-item label="生成步数">{{ steps }}</el-descriptions-item>
<el-descriptions-item label="CFG Scale">{{ cfgScale }}</el-descriptions-item>
<el-descriptions-item label="采样器">{{ sampler }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>小提示</strong>
提示词越详细生成效果越好尝试使用 "风格词" "赛博朋克""水彩画"来控制图像风格
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Picture, MagicStick } from '@element-plus/icons-vue'
const canvasRef = ref(null)
const prompt = ref('一只戴着墨镜的猫,赛博朋克风格,霓虹灯光')
const steps = ref(20)
const cfgScale = ref(7.5)
const sampler = ref('euler')
const isGenerating = ref(false)
const progress = ref(0)
const currentStep = ref(0)
const hasGenerated = ref(false)
const presetPrompts = [
{ label: '🐱 赛博朋克猫', prompt: '一只戴着墨镜的猫,赛博朋克风格,霓虹灯光' },
{ label: '🏔️ 山水画', prompt: '中国山水画,云雾缭绕,水墨风格' },
{ label: '🚀 太空', prompt: '宇航员在火星表面,日落时分,科幻风格' },
{ label: '🌸 樱花', prompt: '樱花树下,日本传统建筑,春天,柔和光线' },
]
// 模拟生成过程
const startGeneration = async () => {
isGenerating.value = true
progress.value = 0
currentStep.value = 0
hasGenerated.value = false
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
// 生成噪声图像作为起点
const generateNoise = () => {
const imageData = ctx.createImageData(400, 400)
for (let i = 0; i < imageData.data.length; i += 4) {
const val = Math.random() * 255
imageData.data[i] = val
imageData.data[i + 1] = val
imageData.data[i + 2] = val
imageData.data[i + 3] = 255
}
return imageData
}
// 模拟渐进生成
for (let i = 0; i <= steps.value; i++) {
await new Promise(resolve => setTimeout(resolve, 100))
currentStep.value = i
progress.value = Math.round((i / steps.value) * 100)
// 绘制渐进图像(模拟)
const noiseRatio = 1 - (i / steps.value)
drawSimulatedImage(ctx, noiseRatio)
}
isGenerating.value = false
hasGenerated.value = true
}
// 根据提示词绘制模拟图像
const drawSimulatedImage = (ctx, noiseRatio) => {
const width = 400
const height = 400
// 清空画布
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, width, height)
// 根据提示词关键词绘制不同内容
const promptLower = prompt.value.toLowerCase()
// 赛博朋克风格
if (promptLower.includes('赛博') || promptLower.includes('cyber')) {
drawCyberpunkScene(ctx, width, height, noiseRatio)
}
// 山水画
else if (promptLower.includes('山水') || promptLower.includes('水墨')) {
drawLandscape(ctx, width, height, noiseRatio)
}
// 太空
else if (promptLower.includes('太空') || promptLower.includes('宇航员')) {
drawSpaceScene(ctx, width, height, noiseRatio)
}
// 樱花
else if (promptLower.includes('樱花') || promptLower.includes('日本')) {
drawSakuraScene(ctx, width, height, noiseRatio)
}
// 默认:抽象艺术
else {
drawAbstractArt(ctx, width, height, noiseRatio)
}
// 添加噪声
if (noiseRatio > 0) {
addNoise(ctx, width, height, noiseRatio)
}
}
const drawCyberpunkScene = (ctx, w, h, noise) => {
// 霓虹背景
const gradient = ctx.createLinearGradient(0, 0, 0, h)
gradient.addColorStop(0, '#0a0a1a')
gradient.addColorStop(1, '#1a0a2e')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
// 霓虹灯条
ctx.shadowBlur = 20 * (1 - noise)
ctx.shadowColor = '#ff00ff'
ctx.fillStyle = `rgba(255, 0, 255, ${0.8 * (1 - noise)})`
ctx.fillRect(50, 100, 300, 5)
ctx.shadowColor = '#00ffff'
ctx.fillStyle = `rgba(0, 255, 255, ${0.8 * (1 - noise)})`
ctx.fillRect(100, 200, 200, 5)
// 猫的形状(简化)
ctx.shadowBlur = 0
ctx.fillStyle = `rgba(100, 100, 150, ${0.9 * (1 - noise)})`
ctx.beginPath()
ctx.ellipse(200, 280, 60, 50, 0, 0, Math.PI * 2)
ctx.fill()
// 耳朵
ctx.beginPath()
ctx.moveTo(160, 250)
ctx.lineTo(150, 200)
ctx.lineTo(180, 240)
ctx.fill()
ctx.beginPath()
ctx.moveTo(240, 250)
ctx.lineTo(250, 200)
ctx.lineTo(220, 240)
ctx.fill()
// 墨镜
ctx.fillStyle = `rgba(0, 0, 0, ${0.9 * (1 - noise)})`
ctx.beginPath()
ctx.ellipse(180, 270, 20, 12, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(220, 270, 20, 12, 0, 0, Math.PI * 2)
ctx.fill()
// 镜片反光
ctx.fillStyle = `rgba(255, 0, 255, ${0.6 * (1 - noise)})`
ctx.beginPath()
ctx.ellipse(175, 268, 5, 3, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(215, 268, 5, 3, 0, 0, Math.PI * 2)
ctx.fill()
}
const drawLandscape = (ctx, w, h, noise) => {
// 天空渐变
const gradient = ctx.createLinearGradient(0, 0, 0, h)
gradient.addColorStop(0, '#e8f4f8')
gradient.addColorStop(0.5, '#c8e0e8')
gradient.addColorStop(1, '#a8c8d8')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
// 远山
ctx.fillStyle = `rgba(100, 120, 140, ${0.5 * (1 - noise)})`
ctx.beginPath()
ctx.moveTo(0, 250)
ctx.lineTo(100, 150)
ctx.lineTo(200, 200)
ctx.lineTo(300, 120)
ctx.lineTo(400, 180)
ctx.lineTo(400, 400)
ctx.lineTo(0, 400)
ctx.fill()
// 近山
ctx.fillStyle = `rgba(60, 80, 100, ${0.7 * (1 - noise)})`
ctx.beginPath()
ctx.moveTo(0, 300)
ctx.lineTo(150, 220)
ctx.lineTo(300, 280)
ctx.lineTo(400, 240)
ctx.lineTo(400, 400)
ctx.lineTo(0, 400)
ctx.fill()
// 云雾
ctx.fillStyle = `rgba(255, 255, 255, ${0.4 * (1 - noise)})`
for (let i = 0; i < 5; i++) {
ctx.beginPath()
ctx.ellipse(80 + i * 70, 180 + i * 20, 40, 20, 0, 0, Math.PI * 2)
ctx.fill()
}
}
const drawSpaceScene = (ctx, w, h, noise) => {
// 太空背景
ctx.fillStyle = '#0a0a15'
ctx.fillRect(0, 0, w, h)
// 星星
ctx.fillStyle = `rgba(255, 255, 255, ${1 - noise})`
for (let i = 0; i < 100; i++) {
const x = (i * 37) % w
const y = (i * 73) % h
ctx.beginPath()
ctx.arc(x, y, Math.random() * 2, 0, Math.PI * 2)
ctx.fill()
}
// 火星地面
ctx.fillStyle = `rgba(180, 80, 40, ${0.8 * (1 - noise)})`
ctx.beginPath()
ctx.moveTo(0, 350)
ctx.lineTo(100, 320)
ctx.lineTo(200, 340)
ctx.lineTo(300, 310)
ctx.lineTo(400, 330)
ctx.lineTo(400, 400)
ctx.lineTo(0, 400)
ctx.fill()
// 宇航员(简化)
ctx.fillStyle = `rgba(220, 220, 230, ${0.9 * (1 - noise)})`
ctx.beginPath()
ctx.arc(200, 220, 40, 0, Math.PI * 2)
ctx.fill()
// 头盔反光
ctx.fillStyle = `rgba(255, 200, 100, ${0.5 * (1 - noise)})`
ctx.beginPath()
ctx.arc(185, 210, 15, 0, Math.PI * 2)
ctx.fill()
// 日落
const sunGradient = ctx.createRadialGradient(300, 100, 0, 300, 100, 60)
sunGradient.addColorStop(0, `rgba(255, 200, 100, ${1 - noise})`)
sunGradient.addColorStop(1, `rgba(255, 100, 50, ${0.5 * (1 - noise)})`)
ctx.fillStyle = sunGradient
ctx.beginPath()
ctx.arc(300, 100, 50, 0, Math.PI * 2)
ctx.fill()
}
const drawSakuraScene = (ctx, w, h, noise) => {
// 天空
const gradient = ctx.createLinearGradient(0, 0, 0, h)
gradient.addColorStop(0, '#ffe4e1')
gradient.addColorStop(1, '#ffb6c1')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
// 传统建筑屋顶
ctx.fillStyle = `rgba(80, 60, 60, ${0.9 * (1 - noise)})`
ctx.beginPath()
ctx.moveTo(100, 300)
ctx.lineTo(150, 200)
ctx.lineTo(250, 200)
ctx.lineTo(300, 300)
ctx.fill()
// 樱花树
ctx.fillStyle = `rgba(139, 90, 43, ${0.9 * (1 - noise)})`
ctx.fillRect(50, 200, 20, 200)
// 樱花
ctx.fillStyle = `rgba(255, 183, 197, ${0.8 * (1 - noise)})`
for (let i = 0; i < 30; i++) {
const x = 30 + (i * 13) % 80
const y = 150 + (i * 17) % 100
ctx.beginPath()
ctx.arc(x, y, 8 + Math.random() * 5, 0, Math.PI * 2)
ctx.fill()
}
// 飘落的樱花
ctx.fillStyle = `rgba(255, 192, 203, ${0.6 * (1 - noise)})`
for (let i = 0; i < 20; i++) {
const x = (i * 23) % w
const y = 250 + (i * 31) % 150
ctx.beginPath()
ctx.ellipse(x, y, 4, 2, i, 0, Math.PI * 2)
ctx.fill()
}
}
const drawAbstractArt = (ctx, w, h, noise) => {
// 渐变背景
const gradient = ctx.createLinearGradient(0, 0, w, h)
gradient.addColorStop(0, '#667eea')
gradient.addColorStop(1, '#764ba2')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
// 抽象形状
const colors = ['#f093fb', '#f5576c', '#4facfe', '#00f2fe']
for (let i = 0; i < 8; i++) {
ctx.fillStyle = colors[i % colors.length] + Math.floor((1 - noise) * 255).toString(16).padStart(2, '0')
ctx.beginPath()
const x = 100 + (i * 50) % 300
const y = 100 + (i * 70) % 250
const r = 30 + i * 5
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.fill()
}
}
const addNoise = (ctx, w, h, ratio) => {
const imageData = ctx.getImageData(0, 0, w, h)
for (let i = 0; i < imageData.data.length; i += 4) {
const noise = (Math.random() - 0.5) * 50 * ratio
imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + noise))
imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + noise))
imageData.data[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] + noise))
}
ctx.putImageData(imageData, 0, 0)
}
onMounted(() => {
// 初始绘制
const canvas = canvasRef.value
if (canvas) {
const ctx = canvas.getContext('2d')
drawSimulatedImage(ctx, 1)
}
})
</script>
<style scoped>
.image-gen-quickstart {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.demo-layout {
grid-template-columns: 1fr;
}
}
.control-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-section label,
.params-section label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--vp-c-text-2);
}
.prompt-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.prompt-tag {
cursor: pointer;
transition: all 0.2s;
}
.prompt-tag:hover {
transform: translateY(-2px);
}
.param-row {
margin-bottom: 12px;
}
.param-row label {
font-size: 0.8rem;
margin-bottom: 4px;
}
.generate-btn {
width: 100%;
margin-top: auto;
}
.display-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.canvas-container {
position: relative;
width: 100%;
aspect-ratio: 1;
background: var(--vp-c-bg-mute);
border-radius: 8px;
overflow: hidden;
}
.gen-canvas {
width: 100%;
height: 100%;
object-fit: contain;
}
.progress-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
gap: 16px;
}
.step-info {
color: white;
font-size: 0.9rem;
}
.image-info {
font-size: 0.8rem;
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
</style>
@@ -0,0 +1,651 @@
<!--
LoRADemo.vue
LoRA 微调演示组件
用途
展示 LoRA (Low-Rank Adaptation) 如何以轻量级方式微调模型实现特定风格或角色的生成
交互功能
- LoRA 权重调节
- 基础模型 + LoRA 组合展示
- 对比不同权重的生成效果
- LoRA 融合可视化
-->
<template>
<div class="lora-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Collection /></el-icon>
<span>🎨 LoRA轻量级微调</span>
</div>
</template>
<div class="demo-content">
<!-- LoRA 概念说明 -->
<div class="concept-section">
<div class="concept-visual">
<div class="model-box base">
<div class="box-title">基础模型</div>
<div class="box-size">4-8 GB</div>
<div class="box-desc">通用知识</div>
</div>
<div class="plus-sign">+</div>
<div class="model-box lora">
<div class="box-title">LoRA 权重</div>
<div class="box-size">50-200 MB</div>
<div class="box-desc">特定风格/角色</div>
</div>
<div class="equals-sign">=</div>
<div class="model-box result">
<div class="box-title">定制模型</div>
<div class="box-size">无需合并</div>
<div class="box-desc">动态加载</div>
</div>
</div>
</div>
<!-- LoRA 权重调节 -->
<div class="weight-control-section">
<div class="weight-header">
<span>LoRA 权重调节</span>
<el-tag type="primary" effect="dark">{{ loraWeight }}</el-tag>
</div>
<el-slider
v-model="loraWeight"
:min="0"
:max="1.5"
:step="0.1"
show-stops
:marks="{
0: '无效果',
0.5: '轻微',
1: '标准',
1.5: '强烈'
}"
/>
<div class="lora-selector">
<el-radio-group v-model="selectedLoRA">
<el-radio-button label="anime">动漫风格</el-radio-button>
<el-radio-button label="realistic">写实风格</el-radio-button>
<el-radio-button label="sketch">素描风格</el-radio-button>
<el-radio-button label="3d">3D 风格</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 效果对比 -->
<div class="comparison-section">
<div class="comparison-title">生成效果对比</div>
<div class="comparison-grid">
<div class="comparison-item">
<div class="item-label">
<el-tag type="info">仅基础模型</el-tag>
</div>
<canvas
ref="baseCanvas"
width="200"
height="200"
class="comparison-canvas"
/>
<div class="item-desc">通用风格</div>
</div>
<div class="comparison-item main">
<div class="item-label">
<el-tag type="success">基础 + LoRA ({{ loraWeight }})</el-tag>
</div>
<canvas
ref="loraCanvas"
width="200"
height="200"
class="comparison-canvas main-canvas"
/>
<div class="item-desc">{{ getLoRADescription() }}</div>
</div>
</div>
</div>
<!-- LoRA 融合 -->
<div class="fusion-section">
<div class="fusion-title">🔀 LoRA 融合</div>
<div class="fusion-controls">
<div
v-for="(lora, index) in activeLoRAs"
:key="index"
class="fusion-item"
>
<el-tag :type="lora.type" closable @close="removeLoRA(index)">
{{ lora.name }}
</el-tag>
<el-slider
v-model="lora.weight"
:min="0"
:max="1"
:step="0.1"
size="small"
style="width: 120px"
/>
<span class="weight-display">{{ lora.weight }}</span>
</div>
<el-dropdown @command="addLoRA">
<el-button type="primary" size="small">
<el-icon><Plus /></el-icon> 添加 LoRA
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="anime">动漫风格</el-dropdown-item>
<el-dropdown-item command="realistic">写实风格</el-dropdown-item>
<el-dropdown-item command="sketch">素描风格</el-dropdown-item>
<el-dropdown-item command="3d">3D 风格</el-dropdown-item>
<el-dropdown-item command="watercolor">水彩风格</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="fusion-result">
<canvas
ref="fusionCanvas"
width="250"
height="250"
class="fusion-canvas"
/>
<div class="fusion-formula">
<div class="formula-title">融合公式</div>
<div class="formula-content">
输出 = 基础模型 + Σ(LoRAᵢ × 权重ᵢ)
</div>
</div>
</div>
</div>
<!-- 应用场景 -->
<div class="use-cases">
<div class="use-cases-title">🎯 LoRA 典型应用</div>
<div class="use-cases-grid">
<div class="use-case-card">
<div class="use-case-icon">👤</div>
<div class="use-case-title">角色一致性</div>
<div class="use-case-desc">训练特定角色保持形象一致</div>
</div>
<div class="use-case-card">
<div class="use-case-icon">🎨</div>
<div class="use-case-title">艺术风格</div>
<div class="use-case-desc">模仿特定画家或艺术风格</div>
</div>
<div class="use-case-card">
<div class="use-case-icon">👗</div>
<div class="use-case-title">服装概念</div>
<div class="use-case-desc">特定服装或配饰设计</div>
</div>
<div class="use-case-card">
<div class="use-case-icon">🏢</div>
<div class="use-case-title">产品展示</div>
<div class="use-case-desc">特定产品或品牌风格</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>LoRA 原理</strong>
LoRA 通过在原始权重矩阵旁边添加低秩矩阵来进行微调只训练少量参数通常 < 1%就能实现特定风格或角色的学习相比完整微调LoRA 文件小训练快可组合使用
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { Collection, Plus } from '@element-plus/icons-vue'
const loraWeight = ref(0.8)
const selectedLoRA = ref('anime')
const baseCanvas = ref(null)
const loraCanvas = ref(null)
const fusionCanvas = ref(null)
const activeLoRAs = ref([
{ name: '动漫风格', type: 'primary', weight: 0.6 },
{ name: '水彩效果', type: 'success', weight: 0.3 }
])
const loraTypes = {
anime: { name: '动漫风格', type: 'primary', color: '#FFB6C1' },
realistic: { name: '写实风格', type: 'success', color: '#DDA0DD' },
sketch: { name: '素描风格', type: 'warning', color: '#D3D3D3' },
'3d': { name: '3D 风格', type: 'danger', color: '#87CEEB' },
watercolor: { name: '水彩效果', type: 'info', color: '#98FB98' }
}
const getLoRADescription = () => {
const descriptions = {
anime: '大眼睛、鲜明色彩的动漫风格',
realistic: '照片级真实感',
sketch: '手绘线条和阴影',
'3d': '立体感和材质渲染',
watercolor: '柔和的水彩晕染效果'
}
return descriptions[selectedLoRA.value] || ''
}
const addLoRA = (command) => {
const loraInfo = loraTypes[command]
if (loraInfo) {
activeLoRAs.value.push({
name: loraInfo.name,
type: loraInfo.type,
weight: 0.5
})
}
}
const removeLoRA = (index) => {
activeLoRAs.value.splice(index, 1)
}
// 绘制基础图像
const drawBaseImage = (ctx, width, height) => {
ctx.fillStyle = '#f5f5f5'
ctx.fillRect(0, 0, width, height)
// 绘制一个简单的角色轮廓
ctx.strokeStyle = '#666'
ctx.lineWidth = 2
// 头部
ctx.beginPath()
ctx.arc(width * 0.5, height * 0.3, width * 0.2, 0, Math.PI * 2)
ctx.stroke()
// 身体
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.5)
ctx.lineTo(width * 0.5, height * 0.8)
ctx.stroke()
// 手臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.55)
ctx.lineTo(width * 0.25, height * 0.7)
ctx.moveTo(width * 0.5, height * 0.55)
ctx.lineTo(width * 0.75, height * 0.7)
ctx.stroke()
}
// 绘制 LoRA 效果
const drawLoRAImage = (ctx, width, height, loraType, weight) => {
// 先画基础
drawBaseImage(ctx, width, height)
// 根据 LoRA 类型添加效果
const effects = {
anime: () => {
// 动漫风格:大眼睛、鲜艳色彩
ctx.fillStyle = `rgba(255, 182, 193, ${weight * 0.5})`
ctx.fillRect(0, 0, width, height)
// 大眼睛
ctx.fillStyle = `rgba(100, 149, 237, ${weight})`
ctx.beginPath()
ctx.ellipse(width * 0.42, height * 0.28, width * 0.08 * weight, width * 0.1 * weight, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(width * 0.58, height * 0.28, width * 0.08 * weight, width * 0.1 * weight, 0, 0, Math.PI * 2)
ctx.fill()
},
realistic: () => {
// 写实风格:阴影、细节
ctx.fillStyle = `rgba(139, 69, 19, ${weight * 0.3})`
ctx.fillRect(0, 0, width, height)
// 添加阴影
ctx.fillStyle = `rgba(0, 0, 0, ${weight * 0.2})`
ctx.beginPath()
ctx.ellipse(width * 0.5, height * 0.85, width * 0.3, height * 0.05, 0, 0, Math.PI * 2)
ctx.fill()
},
sketch: () => {
// 素描风格:线条、交叉阴影
ctx.strokeStyle = `rgba(0, 0, 0, ${weight * 0.5})`
ctx.lineWidth = 1
for (let i = 0; i < 10; i++) {
ctx.beginPath()
ctx.moveTo(0, i * height * 0.1)
ctx.lineTo(width, i * height * 0.1 + height * 0.1)
ctx.stroke()
}
},
'3d': () => {
// 3D 风格:渐变、立体感
const gradient = ctx.createRadialGradient(
width * 0.3, height * 0.3, 0,
width * 0.5, height * 0.5, width * 0.6
)
gradient.addColorStop(0, `rgba(255, 255, 255, ${weight * 0.5})`)
gradient.addColorStop(1, `rgba(0, 0, 0, ${weight * 0.2})`)
ctx.fillStyle = gradient
ctx.fillRect(0, 0, width, height)
}
}
if (effects[loraType]) {
effects[loraType]()
}
}
// 绘制融合效果
const drawFusionImage = (ctx, width, height) => {
ctx.fillStyle = '#f5f5f5'
ctx.fillRect(0, 0, width, height)
// 基础图像
drawBaseImage(ctx, width, height)
// 叠加所有 LoRA 效果
activeLoRAs.value.forEach(lora => {
const loraKey = Object.keys(loraTypes).find(
key => loraTypes[key].name === lora.name
)
if (loraKey) {
ctx.save()
ctx.globalAlpha = lora.weight
drawLoRAImage(ctx, width, height, loraKey, 1)
ctx.restore()
}
})
}
const updateDisplay = () => {
if (baseCanvas.value) {
const ctx = baseCanvas.value.getContext('2d')
drawBaseImage(ctx, 200, 200)
}
if (loraCanvas.value) {
const ctx = loraCanvas.value.getContext('2d')
drawLoRAImage(ctx, 200, 200, selectedLoRA.value, loraWeight.value)
}
if (fusionCanvas.value) {
const ctx = fusionCanvas.value.getContext('2d')
drawFusionImage(ctx, 250, 250)
}
}
onMounted(updateDisplay)
watch([loraWeight, selectedLoRA, activeLoRAs], updateDisplay, { deep: true })
</script>
<style scoped>
.lora-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.concept-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.concept-visual {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.model-box {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px 24px;
text-align: center;
border: 2px solid var(--vp-c-divider);
min-width: 120px;
}
.model-box.base {
border-color: #409eff;
}
.model-box.lora {
border-color: #67c23a;
}
.model-box.result {
border-color: #e6a23c;
}
.box-title {
font-weight: 600;
margin-bottom: 4px;
}
.box-size {
font-size: 0.75rem;
color: var(--vp-c-text-3);
margin-bottom: 4px;
}
.box-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.plus-sign, .equals-sign {
font-size: 1.5rem;
font-weight: 600;
color: var(--vp-c-text-3);
}
.weight-control-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.weight-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.lora-selector {
margin-top: 16px;
display: flex;
justify-content: center;
}
.comparison-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.comparison-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.comparison-grid {
display: flex;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
}
.comparison-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.comparison-item.main {
transform: scale(1.1);
}
.item-label {
font-weight: 500;
}
.comparison-canvas {
width: 160px;
height: 160px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.comparison-canvas.main-canvas {
border-color: var(--vp-c-brand);
}
.item-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.fusion-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.fusion-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.fusion-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-bottom: 20px;
}
.fusion-item {
display: flex;
align-items: center;
gap: 8px;
background: var(--vp-c-bg);
padding: 8px 12px;
border-radius: 6px;
}
.weight-display {
font-size: 0.875rem;
color: var(--vp-c-text-3);
min-width: 40px;
}
.fusion-result {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.fusion-canvas {
width: 200px;
height: 200px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-brand);
}
.fusion-formula {
text-align: center;
}
.formula-title {
font-weight: 500;
margin-bottom: 8px;
}
.formula-content {
font-family: var(--vp-font-family-mono);
font-size: 0.875rem;
background: var(--vp-c-bg);
padding: 12px;
border-radius: 6px;
}
.use-cases {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.use-cases-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.use-cases-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.use-case-card {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.use-case-icon {
font-size: 2rem;
margin-bottom: 8px;
}
.use-case-title {
font-weight: 600;
margin-bottom: 4px;
}
.use-case-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
</style>
@@ -0,0 +1,497 @@
<!--
PromptEngineeringDemo.vue
提示词工程演示组件
用途
展示提示词如何影响生成结果帮助用户理解提示词工程的重要性
交互功能
- 提示词实时编辑
- 关键词提取和高亮
- 权重调节
- 对比不同提示词的效果
-->
<template>
<div class="prompt-engineering-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><EditPen /></el-icon>
<span> 提示词工程实验室</span>
</div>
</template>
<div class="demo-layout">
<!-- 左侧提示词编辑 -->
<div class="prompt-panel">
<div class="prompt-input-section">
<label>提示词 (Prompt)</label>
<el-input
v-model="prompt"
type="textarea"
:rows="4"
placeholder="输入你的提示词..."
/>
</div>
<div class="prompt-analysis">
<div class="analysis-title">关键词分析</div>
<div class="keywords-list">
<div
v-for="(keyword, index) in analyzedKeywords"
:key="index"
class="keyword-item"
:class="keyword.type"
>
<span class="keyword-text">{{ keyword.text }}</span>
<el-slider
v-model="keyword.weight"
:min="0"
:max="2"
:step="0.1"
size="small"
class="weight-slider"
/>
<span class="weight-value">{{ keyword.weight.toFixed(1) }}</span>
</div>
</div>
</div>
<div class="prompt-tips">
<el-collapse>
<el-collapse-item title="💡 提示词技巧">
<ul class="tips-list">
<li><strong>主体描述</strong>明确你要画什么 "一只橘猫"</li>
<li><strong>风格词</strong>指定艺术风格 "水彩画""赛博朋克"</li>
<li><strong>质量词</strong>提升画质 "8k"" masterpiece""highly detailed"</li>
<li><strong>光照</strong>控制光线效果 "golden hour""volumetric lighting"</li>
<li><strong>权重语法</strong>使用 (word:1.5) 增加权重(word:0.5) 降低权重</li>
</ul>
</el-collapse-item>
</el-collapse>
</div>
</div>
<!-- 右侧效果预览 -->
<div class="preview-panel">
<div class="preview-tabs">
<el-tabs v-model="activeTab">
<el-tab-pane label="结构解析" name="structure">
<div class="structure-viz">
<div class="structure-section">
<div class="section-header">
<el-tag type="primary">主体 (Subject)</el-tag>
</div>
<div class="section-content">
{{ extractSubject() || '未检测到主体' }}
</div>
</div>
<div class="structure-section">
<div class="section-header">
<el-tag type="success">风格 (Style)</el-tag>
</div>
<div class="section-content">
{{ extractStyle() || '未检测到风格词' }}
</div>
</div>
<div class="structure-section">
<div class="section-header">
<el-tag type="warning">质量 (Quality)</el-tag>
</div>
<div class="section-content">
{{ extractQuality() || '未检测到质量词' }}
</div>
</div>
<div class="structure-section">
<div class="section-header">
<el-tag type="info">环境 (Environment)</el-tag>
</div>
<div class="section-content">
{{ extractEnvironment() || '未检测到环境描述' }}
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="对比示例" name="comparison">
<div class="comparison-list">
<div
v-for="(example, index) in promptExamples"
:key="index"
class="comparison-item"
:class="{ active: selectedExample === index }"
@click="selectExample(index)"
>
<div class="example-prompt">{{ example.prompt }}</div>
<div class="example-desc">{{ example.description }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="负面提示词" name="negative">
<div class="negative-prompt-section">
<label>负面提示词 (Negative Prompt)</label>
<el-input
v-model="negativePrompt"
type="textarea"
:rows="3"
placeholder="输入你不希望出现的内容..."
/>
<div class="negative-presets">
<el-tag
v-for="preset in negativePresets"
:key="preset"
size="small"
class="negative-preset-tag"
@click="addNegativePreset(preset)"
>
+ {{ preset }}
</el-tag>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>提示词工程的核心</strong>
好的提示词 = 清晰的描述 + 适当的风格词 + 质量增强词通过调整不同部分的权重可以精确控制生成结果
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { EditPen } from '@element-plus/icons-vue'
const prompt = ref('一只橘猫,坐在窗台上,阳光照射,水彩画风格,8k高清')
const negativePrompt = ref('模糊, 低质量, 变形, 多余的手指')
const activeTab = ref('structure')
const selectedExample = ref(0)
// 关键词类型
const keywordTypes = {
subject: ['猫', '狗', '人', '风景', '建筑', '汽车', '花', '树'],
style: ['水彩', '油画', '素描', '赛博朋克', '像素', '写实', '卡通', '动漫'],
quality: ['8k', '高清', ' masterpiece', 'detailed', 'high quality', '4k', 'sharp'],
environment: ['阳光', '雨天', '夜晚', '森林', '城市', '海边', '室内', '户外']
}
// 分析关键词
const analyzedKeywords = computed(() => {
const keywords = []
const words = prompt.value.split(/[,\s]+/).filter(w => w.length > 0)
words.forEach(word => {
let type = 'other'
if (keywordTypes.subject.some(k => word.includes(k))) type = 'subject'
else if (keywordTypes.style.some(k => word.includes(k))) type = 'style'
else if (keywordTypes.quality.some(k => word.toLowerCase().includes(k.toLowerCase()))) type = 'quality'
else if (keywordTypes.environment.some(k => word.includes(k))) type = 'environment'
keywords.push({
text: word,
type,
weight: 1.0
})
})
return keywords
})
// 提取不同类型的词
const extractSubject = () => {
return analyzedKeywords.value
.filter(k => k.type === 'subject')
.map(k => k.text)
.join(', ')
}
const extractStyle = () => {
return analyzedKeywords.value
.filter(k => k.type === 'style')
.map(k => k.text)
.join(', ')
}
const extractQuality = () => {
return analyzedKeywords.value
.filter(k => k.type === 'quality')
.map(k => k.text)
.join(', ')
}
const extractEnvironment = () => {
return analyzedKeywords.value
.filter(k => k.type === 'environment')
.map(k => k.text)
.join(', ')
}
// 提示词示例
const promptExamples = [
{
prompt: '一只猫',
description: '基础描述,结果可能不够理想'
},
{
prompt: '一只橘猫,坐在窗台上',
description: '添加主体细节和场景'
},
{
prompt: '一只橘猫,坐在窗台上,阳光照射,水彩画风格',
description: '添加光照和风格'
},
{
prompt: '一只橘猫,坐在窗台上,阳光照射,水彩画风格,8k高清, masterpiece',
description: '完整提示词,包含质量词'
}
]
// 负面提示词预设
const negativePresets = [
'模糊',
'低质量',
'变形',
'多余的手指',
'扭曲的脸',
'噪点',
'水印',
'文字'
]
const selectExample = (index) => {
selectedExample.value = index
prompt.value = promptExamples[index].prompt
}
const addNegativePreset = (preset) => {
if (!negativePrompt.value.includes(preset)) {
negativePrompt.value = negativePrompt.value
? `${negativePrompt.value}, ${preset}`
: preset
}
}
</script>
<style scoped>
.prompt-engineering-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.demo-layout {
grid-template-columns: 1fr;
}
}
.prompt-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.prompt-input-section label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--vp-c-text-2);
}
.prompt-analysis {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
}
.analysis-title {
font-weight: 500;
margin-bottom: 12px;
}
.keywords-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.keyword-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--vp-c-bg);
border-radius: 6px;
border-left: 3px solid var(--vp-c-divider);
}
.keyword-item.subject {
border-left-color: #409eff;
}
.keyword-item.style {
border-left-color: #67c23a;
}
.keyword-item.quality {
border-left-color: #e6a23c;
}
.keyword-item.environment {
border-left-color: #909399;
}
.keyword-text {
min-width: 80px;
font-size: 0.875rem;
}
.weight-slider {
flex: 1;
}
.weight-value {
min-width: 40px;
text-align: right;
font-size: 0.875rem;
color: var(--vp-c-text-3);
}
.prompt-tips {
margin-top: 8px;
}
.tips-list {
margin: 0;
padding-left: 20px;
}
.tips-list li {
margin-bottom: 8px;
line-height: 1.6;
}
.preview-panel {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
}
.structure-viz {
display: flex;
flex-direction: column;
gap: 16px;
}
.structure-section {
background: var(--vp-c-bg);
border-radius: 6px;
padding: 12px;
}
.section-header {
margin-bottom: 8px;
}
.section-content {
font-size: 0.9rem;
color: var(--vp-c-text-2);
min-height: 24px;
}
.comparison-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.comparison-item {
padding: 12px;
background: var(--vp-c-bg);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.comparison-item:hover {
border-color: var(--vp-c-brand);
}
.comparison-item.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.example-prompt {
font-weight: 500;
margin-bottom: 4px;
}
.example-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.negative-prompt-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.negative-prompt-section label {
font-size: 0.875rem;
font-weight: 500;
}
.negative-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.negative-preset-tag {
cursor: pointer;
transition: all 0.2s;
}
.negative-preset-tag:hover {
transform: translateY(-2px);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
</style>
@@ -0,0 +1,559 @@
<!--
SamplerComparisonDemo.vue
采样器对比演示组件
用途
展示不同采样器Euler, DPM++, DDIM 的生成特点帮助用户选择合适的采样器
交互功能
- 采样器选择对比
- 步数调节
- 生成路径可视化
- 速度/质量权衡展示
-->
<template>
<div class="sampler-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Timer /></el-icon>
<span> 采样器对比</span>
</div>
</template>
<div class="demo-content">
<!-- 采样器列表 -->
<div class="sampler-list">
<div
v-for="sampler in samplers"
:key="sampler.id"
class="sampler-card"
:class="{ active: selectedSampler === sampler.id }"
@click="selectedSampler = sampler.id"
>
<div class="sampler-header">
<span class="sampler-name">{{ sampler.name }}</span>
<el-tag :type="sampler.speed" size="small">{{ sampler.speedLabel }}</el-tag>
</div>
<div class="sampler-desc">{{ sampler.description }}</div>
<div class="sampler-pros-cons">
<div class="pros">
<el-icon><CircleCheck /></el-icon>
{{ sampler.pros }}
</div>
<div class="cons">
<el-icon><CircleClose /></el-icon>
{{ sampler.cons }}
</div>
</div>
</div>
</div>
<!-- 可视化对比 -->
<div class="visualization-section">
<div class="viz-header">
<span class="viz-title">生成路径可视化</span>
<el-slider
v-model="steps"
:min="10"
:max="50"
:step="5"
show-stops
style="width: 200px"
/>
<span class="steps-label">{{ steps }} </span>
</div>
<div class="path-visualization">
<canvas
ref="pathCanvas"
width="600"
height="300"
class="path-canvas"
/>
</div>
<div class="sampler-details">
<el-descriptions :column="2" border>
<el-descriptions-item label="推荐步数">
{{ currentSampler.recommendedSteps }}
</el-descriptions-item>
<el-descriptions-item label="收敛速度">
{{ currentSampler.convergence }}
</el-descriptions-item>
<el-descriptions-item label="适用场景">
{{ currentSampler.useCase }}
</el-descriptions-item>
<el-descriptions-item label="稳定性">
<el-rate
:model-value="currentSampler.stability"
disabled
show-score
text-color="#ff9900"
/>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<!-- 推荐矩阵 -->
<div class="recommendation-matrix">
<div class="matrix-title">🎯 采样器选择指南</div>
<div class="matrix-grid">
<div class="matrix-row header">
<div class="matrix-cell">场景</div>
<div class="matrix-cell">推荐采样器</div>
<div class="matrix-cell">原因</div>
</div>
<div
v-for="rec in recommendations"
:key="rec.scenario"
class="matrix-row"
>
<div class="matrix-cell scenario">{{ rec.scenario }}</div>
<div class="matrix-cell">
<el-tag type="primary">{{ rec.sampler }}</el-tag>
</div>
<div class="matrix-cell reason">{{ rec.reason }}</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>采样器的作用</strong>
采样器决定了如何从噪声中逐步恢复图像不同的采样器有不同的数学特性影响生成速度质量和稳定性
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Timer, CircleCheck, CircleClose } from '@element-plus/icons-vue'
const selectedSampler = ref('euler')
const steps = ref(20)
const pathCanvas = ref(null)
const samplers = [
{
id: 'euler',
name: 'Euler',
speed: 'success',
speedLabel: '快速',
description: '最简单高效的采样器,适合快速预览',
pros: '速度快,内存占用低',
cons: '步数少时可能不够精细',
recommendedSteps: '20-30',
convergence: '中等',
useCase: '快速迭代、草图生成',
stability: 3
},
{
id: 'euler_a',
name: 'Euler a',
speed: 'success',
speedLabel: '快速',
description: 'Euler 的祖先版本,更具创造性',
pros: '生成结果更有创意',
cons: '收敛性较差,结果不稳定',
recommendedSteps: '25-35',
convergence: '慢',
useCase: '艺术创作、探索性生成',
stability: 2
},
{
id: 'dpm',
name: 'DPM++ 2M',
speed: 'warning',
speedLabel: '中等',
description: '当前最流行的采样器,平衡了速度和质量',
pros: '质量高,收敛快',
cons: '计算量稍大',
recommendedSteps: '20-30',
convergence: '快',
useCase: '大多数场景的首选',
stability: 5
},
{
id: 'dpm_karras',
name: 'DPM++ 2M Karras',
speed: 'warning',
speedLabel: '中等',
description: '使用 Karras 噪声调度的 DPM++',
pros: '低步数也能出好效果',
cons: '需要更多显存',
recommendedSteps: '15-25',
convergence: '很快',
useCase: '高质量最终输出',
stability: 5
},
{
id: 'ddim',
name: 'DDIM',
speed: 'danger',
speedLabel: '较慢',
description: '确定性采样器,可复现结果',
pros: '确定性,相同种子结果一致',
cons: '速度较慢',
recommendedSteps: '25-50',
convergence: '中等',
useCase: '需要可复现结果的场景',
stability: 4
},
{
id: 'uni_pc',
name: 'UniPC',
speed: 'success',
speedLabel: '快速',
description: '新型采样器,5-10 步即可出图',
pros: '极快,低步数效果好',
cons: '较新,兼容性待验证',
recommendedSteps: '5-15',
convergence: '极快',
useCase: '实时应用、快速预览',
stability: 4
}
]
const currentSampler = computed(() => {
return samplers.find(s => s.id === selectedSampler.value) || samplers[0]
})
const recommendations = [
{
scenario: '快速预览',
sampler: 'Euler / UniPC',
reason: '步数少,速度快,适合快速尝试不同提示词'
},
{
scenario: '最终输出',
sampler: 'DPM++ 2M Karras',
reason: '质量高,收敛快,15-20 步即可出高质量图'
},
{
scenario: '艺术创作',
sampler: 'Euler a',
reason: '结果更有创意和随机性,适合探索'
},
{
scenario: '需要可复现',
sampler: 'DDIM',
reason: '确定性采样,相同参数结果完全一致'
}
]
// 绘制采样路径可视化
const drawPathVisualization = () => {
const canvas = pathCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
// 清空画布
ctx.fillStyle = '#f5f5f5'
ctx.fillRect(0, 0, width, height)
// 绘制坐标轴
ctx.strokeStyle = '#ccc'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(40, height - 40)
ctx.lineTo(width - 20, height - 40)
ctx.moveTo(40, height - 40)
ctx.lineTo(40, 20)
ctx.stroke()
// 标签
ctx.fillStyle = '#666'
ctx.font = '12px sans-serif'
ctx.fillText('步数 →', width - 60, height - 20)
ctx.save()
ctx.translate(20, height / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillText('图像质量 →', 0, 0)
ctx.restore()
// 绘制不同采样器的收敛曲线
const samplerCurves = {
euler: { color: '#67c23a', curve: t => 1 - Math.exp(-t * 2) },
euler_a: { color: '#e6a23c', curve: t => 1 - Math.exp(-t * 1.5) + Math.sin(t * 10) * 0.05 },
dpm: { color: '#409eff', curve: t => 1 - Math.exp(-t * 3) },
dpm_karras: { color: '#409eff', curve: t => 1 - Math.exp(-t * 4), dashed: true },
ddim: { color: '#f56c6c', curve: t => 1 - Math.exp(-t * 1.8) },
uni_pc: { color: '#909399', curve: t => 1 - Math.exp(-t * 5) }
}
const plotWidth = width - 60
const plotHeight = height - 60
Object.entries(samplerCurves).forEach(([id, config]) => {
if (id !== selectedSampler.value && id !== 'dpm_karras') return
ctx.strokeStyle = config.color
ctx.lineWidth = id === selectedSampler.value ? 3 : 2
ctx.setLineDash(config.dashed ? [5, 5] : [])
ctx.beginPath()
for (let i = 0; i <= steps.value; i++) {
const t = i / 50
const x = 40 + (i / 50) * plotWidth
const y = height - 40 - config.curve(t) * plotHeight * 0.9
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.stroke()
})
ctx.setLineDash([])
// 绘制当前步数标记
const currentX = 40 + (steps.value / 50) * plotWidth
ctx.strokeStyle = '#ff6b6b'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(currentX, 20)
ctx.lineTo(currentX, height - 40)
ctx.stroke()
// 标记点
const selectedCurve = samplerCurves[selectedSampler.value]
const currentT = steps.value / 50
const currentY = height - 40 - selectedCurve.curve(currentT) * plotHeight * 0.9
ctx.fillStyle = '#ff6b6b'
ctx.beginPath()
ctx.arc(currentX, currentY, 6, 0, Math.PI * 2)
ctx.fill()
// 图例
let legendY = 30
ctx.font = '12px sans-serif'
Object.entries(samplerCurves).forEach(([id, config]) => {
if (id !== selectedSampler.value) return
ctx.fillStyle = config.color
ctx.fillRect(width - 120, legendY, 15, 3)
ctx.fillStyle = '#666'
ctx.fillText(samplers.find(s => s.id === id)?.name || id, width - 100, legendY + 5)
legendY += 20
})
}
onMounted(drawPathVisualization)
watch([selectedSampler, steps], drawPathVisualization)
</script>
<style scoped>
.sampler-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.sampler-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.sampler-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.sampler-card:hover {
border-color: var(--vp-c-brand);
}
.sampler-card.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.sampler-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sampler-name {
font-weight: 600;
font-size: 1.1rem;
}
.sampler-desc {
font-size: 0.875rem;
color: var(--vp-c-text-2);
margin-bottom: 12px;
}
.sampler-pros-cons {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
}
.pros {
color: #67c23a;
display: flex;
align-items: center;
gap: 4px;
}
.cons {
color: #f56c6c;
display: flex;
align-items: center;
gap: 4px;
}
.visualization-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.viz-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.viz-title {
font-weight: 500;
}
.steps-label {
font-size: 0.875rem;
color: var(--vp-c-text-2);
}
.path-visualization {
background: var(--vp-c-bg);
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
}
.path-canvas {
width: 100%;
height: auto;
max-height: 300px;
}
.sampler-details {
margin-top: 16px;
}
.recommendation-matrix {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.matrix-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.matrix-grid {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.matrix-row {
display: grid;
grid-template-columns: 1fr 1.5fr 2fr;
background: var(--vp-c-bg);
}
.matrix-row.header {
background: var(--vp-c-bg-mute);
font-weight: 600;
}
.matrix-cell {
padding: 12px;
display: flex;
align-items: center;
}
.matrix-cell.scenario {
font-weight: 500;
}
.matrix-cell.reason {
font-size: 0.875rem;
color: var(--vp-c-text-2);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.matrix-row {
grid-template-columns: 1fr;
gap: 8px;
padding: 12px;
}
.matrix-row.header {
display: none;
}
.matrix-cell {
padding: 4px;
}
.matrix-cell::before {
content: attr(data-label);
font-weight: 600;
margin-right: 8px;
}
}
</style>
@@ -0,0 +1,535 @@
<!--
UNetDenoiseDemo.vue
UNet 去噪过程演示组件
用途
展示 UNet/DiT 如何从噪声中逐步恢复图像理解扩散模型的核心去噪机制
交互功能
- 单步/自动播放去噪过程
- 可视化噪声预测
- 展示不同时间步的预测结果
- 对比有/无文本引导的生成
-->
<template>
<div class="unet-demo">
<el-card shadow="never">
<template #header>
<div class="header-controls">
<span class="title">🧠 UNet 去噪模型</span>
<div class="controls">
<el-button-group>
<el-button @click="stepBackward" :disabled="currentStep <= 0">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button @click="togglePlay">
<el-icon v-if="isPlaying"><VideoPause /></el-icon>
<el-icon v-else><VideoPlay /></el-icon>
</el-button>
<el-button @click="stepForward" :disabled="currentStep >= totalSteps">
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
<el-button @click="reset">重置</el-button>
</div>
</div>
</template>
<div class="demo-content">
<!-- 主展示区 -->
<div class="main-display">
<div class="display-section">
<div class="section-label">当前噪声图像 (Noisy Image)</div>
<canvas
ref="noisyCanvas"
width="256"
height="256"
class="display-canvas"
/>
<div class="timestep-info">
<el-tag type="info">Timestep: {{ currentStep }} / {{ totalSteps }}</el-tag>
<el-tag :type="getNoiseLevelType()">噪声强度: {{ getNoiseLevel() }}%</el-tag>
</div>
</div>
<div class="arrow-section">
<el-icon :size="24"><ArrowRight /></el-icon>
<div class="model-box">
<div class="model-name">UNet / DiT</div>
<div class="model-desc">预测噪声</div>
</div>
<el-icon :size="24"><ArrowRight /></el-icon>
</div>
<div class="display-section">
<div class="section-label">预测的噪声 (Predicted Noise)</div>
<canvas
ref="noiseCanvas"
width="256"
height="256"
class="display-canvas noise-preview"
/>
<div class="noise-stats">
<el-tag size="small" type="warning">噪声估计</el-tag>
</div>
</div>
<div class="arrow-section">
<el-icon :size="24"><ArrowRight /></el-icon>
<div class="operation-box">
<div class="op-name">减法</div>
<div class="op-formula">x - ε</div>
</div>
<el-icon :size="24"><ArrowRight /></el-icon>
</div>
<div class="display-section">
<div class="section-label">去噪结果 (Denoised)</div>
<canvas
ref="denoisedCanvas"
width="256"
height="256"
class="display-canvas"
/>
<div class="progress-info">
<el-progress
:percentage="(currentStep / totalSteps) * 100"
:status="currentStep === totalSteps ? 'success' : ''"
/>
</div>
</div>
</div>
<!-- 时间轴 -->
<div class="timeline-section">
<div class="timeline-label">去噪时间轴</div>
<el-slider
v-model="currentStep"
:min="0"
:max="totalSteps"
:step="1"
show-stops
:marks="marks"
@input="updateDisplay"
/>
</div>
<!-- 对比模式 -->
<div class="compare-section">
<el-switch
v-model="showComparison"
active-text="显示对比 (/无文本引导)"
/>
<div v-if="showComparison" class="compare-display">
<div class="compare-item">
<div class="compare-label">无引导 (Unconditional)</div>
<canvas
ref="uncondCanvas"
width="200"
height="200"
class="compare-canvas"
/>
</div>
<div class="compare-item">
<div class="compare-label">有引导 (CFG Scale=7.5)</div>
<canvas
ref="condCanvas"
width="200"
height="200"
class="compare-canvas"
/>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>去噪原理</strong>
UNet 学习预测图像中的噪声然后用原图减去预测的噪声得到更清晰的结果重复这个过程直到从纯噪声恢复出清晰图像
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ArrowRight, ArrowLeft, VideoPlay, VideoPause } from '@element-plus/icons-vue'
const noisyCanvas = ref(null)
const noiseCanvas = ref(null)
const denoisedCanvas = ref(null)
const uncondCanvas = ref(null)
const condCanvas = ref(null)
const currentStep = ref(0)
const totalSteps = 20
const isPlaying = ref(false)
const showComparison = ref(false)
const marks = {
0: '纯噪声',
10: '中期',
20: '清晰图'
}
let animationId = null
// 生成目标图像(简化版)
const generateTargetImage = () => {
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext('2d')
// 绘制简单的目标图案
const gradient = ctx.createLinearGradient(0, 0, 256, 256)
gradient.addColorStop(0, '#667eea')
gradient.addColorStop(1, '#764ba2')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 256, 256)
// 添加一些形状
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'
for (let i = 0; i < 5; i++) {
ctx.beginPath()
ctx.arc(50 + i * 40, 100 + (i % 2) * 50, 30, 0, Math.PI * 2)
ctx.fill()
}
return ctx.getImageData(0, 0, 256, 256)
}
const targetImage = generateTargetImage()
// 生成噪声
const generateNoise = (width, height, intensity) => {
const data = new Uint8ClampedArray(width * height * 4)
for (let i = 0; i < data.length; i += 4) {
const noise = (Math.random() - 0.5) * intensity * 255
data[i] = 128 + noise
data[i + 1] = 128 + noise
data[i + 2] = 128 + noise
data[i + 3] = 255
}
return new ImageData(data, width, height)
}
// 混合图像和噪声
const blendWithNoise = (imageData, noiseRatio) => {
const result = new Uint8ClampedArray(imageData.data)
for (let i = 0; i < result.length; i += 4) {
const noise = (Math.random() - 0.5) * noiseRatio * 255
result[i] = Math.max(0, Math.min(255, imageData.data[i] * (1 - noiseRatio) + 128 * noiseRatio + noise))
result[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] * (1 - noiseRatio) + 128 * noiseRatio + noise))
result[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] * (1 - noiseRatio) + 128 * noiseRatio + noise))
}
return new ImageData(result, imageData.width, imageData.height)
}
// 预测噪声(简化模拟)
const predictNoise = (width, height, step) => {
const noiseRatio = 1 - (step / totalSteps)
return generateNoise(width, height, noiseRatio * 0.5)
}
// 去噪
const denoise = (noisyData, noiseData, step) => {
const result = new Uint8ClampedArray(noisyData.data)
const denoiseStrength = 0.1 + (step / totalSteps) * 0.4
for (let i = 0; i < result.length; i += 4) {
// 模拟:从噪声图像中减去预测的噪声
const targetR = targetImage.data[i]
const targetG = targetImage.data[i + 1]
const targetB = targetImage.data[i + 2]
const currentR = noisyData.data[i]
const currentG = noisyData.data[i + 1]
const currentB = noisyData.data[i + 2]
result[i] = currentR + (targetR - currentR) * denoiseStrength
result[i + 1] = currentG + (targetG - currentG) * denoiseStrength
result[i + 2] = currentB + (targetB - currentB) * denoiseStrength
}
return new ImageData(result, noisyData.width, noisyData.height)
}
// 更新显示
const updateDisplay = () => {
const step = currentStep.value
const noiseRatio = 1 - (step / totalSteps)
// 绘制噪声图像
const noisyCtx = noisyCanvas.value.getContext('2d')
const noisyData = blendWithNoise(targetImage, noiseRatio)
noisyCtx.putImageData(noisyData, 0, 0)
// 绘制预测的噪声
const noiseCtx = noiseCanvas.value.getContext('2d')
const noiseData = predictNoise(256, 256, step)
noiseCtx.putImageData(noiseData, 0, 0)
// 绘制去噪结果
const denoisedCtx = denoisedCanvas.value.getContext('2d')
const denoisedData = denoise(noisyData, noiseData, step)
denoisedCtx.putImageData(denoisedData, 0, 0)
// 更新对比图
if (showComparison.value && uncondCanvas.value && condCanvas.value) {
// 无条件生成(更多噪声残留)
const uncondCtx = uncondCanvas.value.getContext('2d')
const uncondData = blendWithNoise(targetImage, noiseRatio * 0.3)
uncondCtx.putImageData(uncondData, 0, 0)
// 有条件生成(更清晰)
const condCtx = condCanvas.value.getContext('2d')
condCtx.putImageData(denoisedData, 0, 0)
}
}
const getNoiseLevel = () => {
return Math.round((1 - currentStep.value / totalSteps) * 100)
}
const getNoiseLevelType = () => {
const level = getNoiseLevel()
if (level > 70) return 'danger'
if (level > 30) return 'warning'
return 'success'
}
const stepForward = () => {
if (currentStep.value < totalSteps) {
currentStep.value++
updateDisplay()
}
}
const stepBackward = () => {
if (currentStep.value > 0) {
currentStep.value--
updateDisplay()
}
}
const togglePlay = () => {
if (isPlaying.value) {
stopAnimation()
} else {
startAnimation()
}
}
const startAnimation = () => {
isPlaying.value = true
const animate = () => {
if (!isPlaying.value) return
if (currentStep.value >= totalSteps) {
currentStep.value = 0
} else {
currentStep.value++
}
updateDisplay()
animationId = setTimeout(() => {
requestAnimationFrame(animate)
}, 200)
}
animate()
}
const stopAnimation = () => {
isPlaying.value = false
if (animationId) {
clearTimeout(animationId)
animationId = null
}
}
const reset = () => {
stopAnimation()
currentStep.value = 0
updateDisplay()
}
onMounted(updateDisplay)
onUnmounted(stopAnimation)
</script>
<style scoped>
.unet-demo {
margin: 1rem 0;
}
.header-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.title {
font-weight: 600;
}
.controls {
display: flex;
gap: 8px;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.main-display {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
padding: 16px 0;
}
.display-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.section-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-2);
}
.display-canvas {
width: 200px;
height: 200px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.noise-preview {
filter: grayscale(100%);
}
.timestep-info {
display: flex;
gap: 8px;
}
.arrow-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--vp-c-text-3);
}
.model-box,
.operation-box {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 12px 16px;
text-align: center;
min-width: 80px;
}
.model-name,
.op-name {
font-weight: 600;
font-size: 0.875rem;
}
.model-desc,
.op-formula {
font-size: 0.75rem;
color: var(--vp-c-text-3);
margin-top: 4px;
}
.progress-info {
width: 100%;
max-width: 200px;
}
.timeline-section {
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.timeline-label {
font-weight: 500;
margin-bottom: 12px;
}
.compare-section {
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.compare-display {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
flex-wrap: wrap;
}
.compare-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.compare-label {
font-size: 0.875rem;
color: var(--vp-c-text-2);
}
.compare-canvas {
width: 150px;
height: 150px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 768px) {
.main-display {
flex-direction: column;
}
.arrow-section {
transform: rotate(90deg);
margin: 8px 0;
}
}
</style>
@@ -0,0 +1,359 @@
<!--
VaeEncoderDemo.vue
VAE 编解码器演示组件
用途
展示 VAE 如何将高分辨率图像压缩到潜空间以及如何从潜空间还原图像
帮助用户理解 Latent Space 的概念
交互功能
- 编码/解码模式切换
- 可视化压缩过程
- 展示潜空间表示
- 对比原始图像和重建图像
-->
<template>
<div class="vae-demo">
<el-card shadow="never">
<template #header>
<div class="header-controls">
<span class="title">🔍 VAE 编解码器</span>
<el-radio-group v-model="mode" size="small">
<el-radio-button label="encode">
<el-icon><ArrowRight /></el-icon> 编码 (Encode)
</el-radio-button>
<el-radio-button label="decode">
<el-icon><ArrowLeft /></el-icon> 解码 (Decode)
</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="vae-flow">
<!-- 输入侧 -->
<div class="stage">
<div class="stage-label">{{ mode === 'encode' ? '原始图像' : '潜空间表示' }}</div>
<div class="stage-visual">
<canvas
ref="inputCanvas"
width="200"
height="200"
class="stage-canvas"
/>
</div>
<div class="stage-info">
<el-tag size="small" type="info">
{{ mode === 'encode' ? '512 × 512 × 3 = 786,432 数值' : '64 × 64 × 4 = 16,384 数值' }}
</el-tag>
</div>
</div>
<!-- 箭头 -->
<div class="arrow-stage">
<el-icon class="flow-arrow" :size="32">
<component :is="mode === 'encode' ? ArrowRight : ArrowLeft" />
</el-icon>
<div class="compression-ratio">
<el-tag type="success" effect="dark">压缩率: 48×</el-tag>
</div>
</div>
<!-- 输出侧 -->
<div class="stage">
<div class="stage-label">{{ mode === 'encode' ? '潜空间表示' : '重建图像' }}</div>
<div class="stage-visual">
<canvas
ref="outputCanvas"
width="200"
height="200"
class="stage-canvas"
/>
</div>
<div class="stage-info">
<el-tag size="small" type="info">
{{ mode === 'encode' ? '64 × 64 × 4 = 16,384 数值' : '512 × 512 × 3 = 786,432 数值' }}
</el-tag>
</div>
</div>
</div>
<!-- 潜空间可视化 -->
<div class="latent-viz" v-if="mode === 'encode'">
<div class="latent-title">潜空间特征图 (4 个通道)</div>
<div class="latent-channels">
<div
v-for="i in 4"
:key="i"
class="channel-box"
:style="getChannelStyle(i)"
>
<span class="channel-label">Channel {{ i }}</span>
</div>
</div>
</div>
<div class="explanation">
<el-alert
:title="mode === 'encode' ? '编码:图像 → 潜空间' : '解码:潜空间 → 图像'"
:type="mode === 'encode' ? 'warning' : 'success'"
:description="mode === 'encode'
? 'VAE Encoder 将高维图像压缩到低维潜空间,保留关键语义信息,丢弃冗余细节。这就像把一本厚书浓缩成大纲。'
: 'VAE Decoder 从潜空间表示中重建图像。虽然无法完美还原每一个细节,但足以生成高质量的图像。这就像根据大纲重写一本书。'"
show-icon
:closable="false"
/>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>为什么需要 VAE</strong>
直接在像素空间训练扩散模型计算量太大通过 VAE 压缩到潜空间计算效率提升约 48 同时保持图像质量
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ArrowRight, ArrowLeft } from '@element-plus/icons-vue'
const mode = ref('encode')
const inputCanvas = ref(null)
const outputCanvas = ref(null)
// 绘制示例图像
const drawSampleImage = (canvas) => {
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
// 绘制一个风景图
// 天空
const skyGradient = ctx.createLinearGradient(0, 0, 0, h * 0.6)
skyGradient.addColorStop(0, '#87CEEB')
skyGradient.addColorStop(1, '#E0F7FA')
ctx.fillStyle = skyGradient
ctx.fillRect(0, 0, w, h * 0.6)
// 太阳
ctx.beginPath()
ctx.arc(w * 0.75, h * 0.2, w * 0.1, 0, Math.PI * 2)
ctx.fillStyle = '#FFD700'
ctx.fill()
// 山
ctx.fillStyle = '#4CAF50'
ctx.beginPath()
ctx.moveTo(0, h * 0.6)
ctx.lineTo(w * 0.3, h * 0.3)
ctx.lineTo(w * 0.7, h * 0.5)
ctx.lineTo(w, h * 0.4)
ctx.lineTo(w, h)
ctx.lineTo(0, h)
ctx.fill()
// 草地
ctx.fillStyle = '#8BC34A'
ctx.fillRect(0, h * 0.6, w, h * 0.4)
// 花朵
const colors = ['#FF69B4', '#FFD700', '#FF6347', '#9370DB']
for (let i = 0; i < 8; i++) {
const x = (i * w * 0.12) + 20
const y = h * 0.75 + (i % 2) * 30
ctx.fillStyle = colors[i % colors.length]
ctx.beginPath()
ctx.arc(x, y, 8, 0, Math.PI * 2)
ctx.fill()
}
}
// 绘制潜空间表示(抽象可视化)
const drawLatentRepresentation = (canvas) => {
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
// 生成噪声纹理表示潜空间
const imageData = ctx.createImageData(w, h)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4
// 使用柏林噪声模拟潜空间特征
const value = Math.sin(x * 0.1) * Math.cos(y * 0.1) * 50 + 128
imageData.data[i] = value + Math.random() * 30
imageData.data[i + 1] = value + Math.random() * 30
imageData.data[i + 2] = value + Math.random() * 30
imageData.data[i + 3] = 255
}
}
ctx.putImageData(imageData, 0, 0)
}
// 获取通道样式
const getChannelStyle = (channel) => {
const hues = [200, 120, 30, 280]
return {
background: `linear-gradient(135deg, hsl(${hues[channel - 1]}, 70%, 50%), hsl(${hues[channel - 1]}, 70%, 30%))`
}
}
// 更新显示
const updateDisplay = () => {
if (!inputCanvas.value || !outputCanvas.value) return
if (mode.value === 'encode') {
drawSampleImage(inputCanvas.value)
drawLatentRepresentation(outputCanvas.value)
} else {
drawLatentRepresentation(inputCanvas.value)
drawSampleImage(outputCanvas.value)
}
}
onMounted(updateDisplay)
watch(mode, updateDisplay)
</script>
<style scoped>
.vae-demo {
margin: 1rem 0;
}
.header-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: 600;
}
.vae-flow {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 24px 0;
flex-wrap: wrap;
}
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.stage-label {
font-weight: 500;
color: var(--vp-c-text-2);
}
.stage-visual {
width: 200px;
height: 200px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
overflow: hidden;
border: 2px solid var(--vp-c-divider);
}
.stage-canvas {
width: 100%;
height: 100%;
object-fit: cover;
}
.stage-info {
font-size: 0.75rem;
}
.arrow-stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.flow-arrow {
color: var(--vp-c-brand);
}
.compression-ratio {
font-size: 0.8rem;
}
.latent-viz {
margin-top: 16px;
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.latent-title {
font-weight: 500;
margin-bottom: 12px;
text-align: center;
}
.latent-channels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.channel-box {
aspect-ratio: 1;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.channel-label {
position: absolute;
bottom: 4px;
left: 4px;
font-size: 0.7rem;
color: white;
background: rgba(0, 0, 0, 0.5);
padding: 2px 6px;
border-radius: 3px;
}
.explanation {
margin-top: 16px;
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.vae-flow {
flex-direction: column;
}
.arrow-stage {
transform: rotate(90deg);
}
.latent-channels {
grid-template-columns: repeat(2, 1fr);
}
}
</style>