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:
@@ -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>
|
||||
Reference in New Issue
Block a user