From 6fb98f5812c518cdc8d34cd8b8559b422f2df91e Mon Sep 17 00:00:00 2001 From: petezhuang Date: Mon, 11 Aug 2025 15:25:05 +0800 Subject: [PATCH 01/49] feat(spx-gui):init painter --- spx-gui/src/components/editor/common/painter/painter.vue | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 spx-gui/src/components/editor/common/painter/painter.vue diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue new file mode 100644 index 000000000..e69de29bb From 6d9238ea36c33e6a2334cbaf590a54e708a17cf7 Mon Sep 17 00:00:00 2001 From: petezhuang Date: Mon, 11 Aug 2025 16:51:13 +0800 Subject: [PATCH 02/49] feat(spx-gui):basic infrastructure of painter --- spx-gui/package-lock.json | 13 + spx-gui/package.json | 1 + .../common/painter/components/draw_line.vue | 0 .../editor/common/painter/painter.vue | 653 ++++++++++++++++++ .../editor/sprite/CostumeDetail.vue | 32 +- 5 files changed, 691 insertions(+), 8 deletions(-) create mode 100644 spx-gui/src/components/editor/common/painter/components/draw_line.vue diff --git a/spx-gui/package-lock.json b/spx-gui/package-lock.json index 1d8b35882..8aa4be1b1 100644 --- a/spx-gui/package-lock.json +++ b/spx-gui/package-lock.json @@ -52,6 +52,7 @@ "naive-ui": "^2.38.1", "nanoid": "^5.0.5", "npm": "^10.3.0", + "paper": "^0.12.18", "prettier": "^3.2.2", "property-information": "^6.5.0", "qiniu-js": "^3.4.2", @@ -11255,6 +11256,18 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/paper": { + "version": "0.12.18", + "resolved": "https://registry.npmjs.org/paper/-/paper-0.12.18.tgz", + "integrity": "sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw==", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/spx-gui/package.json b/spx-gui/package.json index 38b149bec..23b187383 100644 --- a/spx-gui/package.json +++ b/spx-gui/package.json @@ -58,6 +58,7 @@ "naive-ui": "^2.38.1", "nanoid": "^5.0.5", "npm": "^10.3.0", + "paper": "^0.12.18", "prettier": "^3.2.2", "property-information": "^6.5.0", "qiniu-js": "^3.4.2", diff --git a/spx-gui/src/components/editor/common/painter/components/draw_line.vue b/spx-gui/src/components/editor/common/painter/components/draw_line.vue new file mode 100644 index 000000000..e69de29bb diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index e69de29bb..162b295e8 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -0,0 +1,653 @@ + + + + + diff --git a/spx-gui/src/components/editor/sprite/CostumeDetail.vue b/spx-gui/src/components/editor/sprite/CostumeDetail.vue index 0fd2b94ef..710545168 100644 --- a/spx-gui/src/components/editor/sprite/CostumeDetail.vue +++ b/spx-gui/src/components/editor/sprite/CostumeDetail.vue @@ -1,21 +1,20 @@ From b04690ec7ab1f381cc480128d746c5deb2b63cc7 Mon Sep 17 00:00:00 2001 From: petezhuang Date: Mon, 11 Aug 2025 17:22:16 +0800 Subject: [PATCH 03/49] feat(spx-gui):Decoupling the painter board and draw line components --- .../common/painter/components/draw_line.vue | 136 ++++++++++++++++++ .../editor/common/painter/painter.vue | 114 +++++---------- 2 files changed, 172 insertions(+), 78 deletions(-) diff --git a/spx-gui/src/components/editor/common/painter/components/draw_line.vue b/spx-gui/src/components/editor/common/painter/components/draw_line.vue index e69de29bb..845c6d360 100644 --- a/spx-gui/src/components/editor/common/painter/components/draw_line.vue +++ b/spx-gui/src/components/editor/common/painter/components/draw_line.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index 162b295e8..effeb5f31 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -51,23 +51,14 @@ @mousemove="handleMouseMove" > - - - - + + @@ -75,6 +66,7 @@ + + diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index fd8215ac6..eae672fea 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -15,6 +15,18 @@ 直线 + + +
+

AI工具

+ +
+

操作

+ + + @@ -90,9 +115,10 @@ import { ref, onMounted, onUnmounted, watch } from 'vue' import paper from 'paper' import DrawLine from './components/draw_line.vue' import DrawBrush from './components/draw_brush.vue' +import AiGenerate from './components/ai_generate.vue' // 工具类型 -type ToolType = 'line' | 'brush' | 'select' +type ToolType = 'line' | 'brush' | 'reshape' // TypeScript 接口定义 interface ExtendedItem extends paper.Item { @@ -113,7 +139,7 @@ const canvasWidth = ref(800) const canvasHeight = ref(600) // 工具状态 -const currentTool = ref('line') +const currentTool = ref(null) const drawLineRef = ref | null>(null) const drawBrushRef = ref | null>(null) // 状态管理 @@ -129,6 +155,9 @@ const imgLoading = ref(true) // 存储背景图片的引用 const backgroundImage = ref(null) +// AI生成弹窗状态 +const aiDialogVisible = ref(false) + const props = defineProps<{ imgSrc: string | null imgLoading: boolean @@ -181,13 +210,13 @@ const loadImageToCanvas = (imageSrc: string): void => { paper.view.update() } - raster.onMouseDown = (event: paper.MouseEvent) => { - if (currentTool.value === 'select') { - selectPathExclusive(null) // 清除路径选择 - raster.selected = true - paper.view.update() - } - } + // raster.onMouseDown = (event: paper.MouseEvent) => { + // if (currentTool.value === 'reshape') { + // selectPathExclusive(null) // 清除路径选择 + // raster.selected = true + // paper.view.update() + // } + // } } // 初始化 Paper.js @@ -273,13 +302,31 @@ const showControlPoints = (path: paper.Path): void => { hideControlPoints() selectPathExclusive(path) + // console.log('显示控制点 - 路径类型:', path.constructor.name, '路径:', path) + if (path && path.segments) { + // console.log('路径段数:', path.segments.length) path.segments.forEach((segment: paper.Segment, index: number) => { const controlPoint = createControlPoint(segment.point) controlPoint.segmentIndex = index controlPoint.parentPath = path controlPoints.value.push(controlPoint) }) + } else if (path instanceof paper.CompoundPath) { + // 处理复合路径 + // console.log('处理复合路径') + path.children.forEach((child: paper.Item, childIndex: number) => { + if (child instanceof paper.Path && child.segments) { + child.segments.forEach((segment: paper.Segment, segIndex: number) => { + const controlPoint = createControlPoint(segment.point) + controlPoint.segmentIndex = segIndex + controlPoint.parentPath = child as paper.Path + controlPoints.value.push(controlPoint) + }) + } + }) + } else { + // console.warn('路径对象没有segments属性:', path) } } @@ -295,19 +342,39 @@ const hideControlPoints = (): void => { allPaths.value.forEach((p: paper.Path) => { p.selected = false }) + if (paper.project) { + paper.project.deselectAll() + } } // 检测点击的路径 const getPathAtPoint = (point: paper.Point): paper.Path | null => { + // 扩大检测范围,同时检测填充和描边 const hitResult = paper.project.hitTest(point, { segments: false, stroke: true, - fill: false, - tolerance: 12 + fill: true, // 也检测填充区域 + tolerance: 15 // 增加容差 }) if (hitResult && hitResult.item && !(hitResult.item as ExtendedItem).isControlPoint) { - return hitResult.item as paper.Path + let targetItem = hitResult.item + + // 如果点击的是组内的对象,需要找到实际的路径对象 + while (targetItem.parent && targetItem.parent !== paper.project.activeLayer) { + targetItem = targetItem.parent + } + + // 检查是否在allPaths数组中 + const pathInArray = allPaths.value.find(path => path === targetItem || path === hitResult.item) + if (pathInArray) { + return pathInArray + } + + // 如果是Path或CompoundPath类型,返回原始点击的对象 + if (hitResult.item instanceof paper.Path || hitResult.item instanceof paper.CompoundPath) { + return hitResult.item as paper.Path + } } return null } @@ -422,8 +489,9 @@ const addControlPointOnPath = (path: paper.Path, clickPoint: paper.Point): Exten return null } -// 鼠标按下事件(用于开始拖拽控制点) +// 鼠标按下事件(变形-用于开始拖拽控制点) const handleMouseDown = (event: MouseEvent): void => { + // console.log('handleMouseDown') const rect = canvasRef.value?.getBoundingClientRect() if (!rect) return @@ -438,7 +506,7 @@ const handleMouseDown = (event: MouseEvent): void => { return } - if (currentTool.value !== 'select') return + if (currentTool.value !== 'reshape') return // 检查是否点击了控制点 const controlPoint = getControlPointAtPoint(point) @@ -474,10 +542,11 @@ const handleCanvasClick = (event: MouseEvent): void => { if (drawLineRef.value) { drawLineRef.value.handleCanvasClick({ x: point.x, y: point.y }) } - } else if (currentTool.value === 'select') { + } else if (currentTool.value === 'reshape') { // 检查是否点击了控制点(优先级最高) const controlPoint = getControlPointAtPoint(point) if (controlPoint) { + // console.log('controlPoint', controlPoint) // 不在这里设置拖拽状态,由 mousedown 处理 return } @@ -485,6 +554,7 @@ const handleCanvasClick = (event: MouseEvent): void => { // 检查是否点击了现有路径 const clickedPath = getPathAtPoint(point) if (clickedPath) { + // console.log('clickedPath', clickedPath) // 仅选中并显示端点(不新增控制点) showControlPoints(clickedPath) paper.view.update() @@ -492,6 +562,7 @@ const handleCanvasClick = (event: MouseEvent): void => { } // 点击空白区域,隐藏控制点 + // console.log('hideControlPoints') hideControlPoints() paper.view.update() } @@ -519,7 +590,7 @@ const handleMouseMove = (event: MouseEvent): void => { // 若按在路径上且尚未开始拖拽,当移动超过阈值时,创建临时控制点并进入拖拽 if ( - currentTool.value === 'select' && + currentTool.value === 'reshape' && !isDragging.value && mouseDownPath.value && mouseDownPos.value @@ -604,6 +675,165 @@ const handleMouseUp = (): void => { } } +// 显示AI生成弹窗 +const showAiDialog = (): void => { + aiDialogVisible.value = true +} + +// 处理AI生成确认 +const handleAiConfirm = (data: { + model: string; + prompt: string; + url?: string; + svgContent?: string; +}): void => { + // console.log('AI生成确认:', data) + + if (data.model === 'svg' && data.svgContent) { + // 处理SVG导入 + importSvgToCanvas(data.svgContent) + } else if (data.model === 'png' && data.url) { + // 处理PNG图片导入 + importImageToCanvas(data.url) + } + + aiDialogVisible.value = false +} + +// 处理AI生成取消 +const handleAiCancel = (): void => { + // console.log('AI生成取消') + aiDialogVisible.value = false +} + +// 导入PNG图片到画布 +const importImageToCanvas = (imageUrl: string): void => { + if (!paper.project) return + + // 创建新的光栅图像 + const raster = new paper.Raster(imageUrl) + + raster.onLoad = () => { + // 图片加载完成后的处理 + // 设置图片位置到画布中心 + raster.position = paper.view.center + + // 调整图片大小适应画布 + const scale = Math.min( + canvasWidth.value / raster.width * 0.6, + canvasHeight.value / raster.height * 0.6 + ) + + if (scale < 1) { + raster.scale(scale) + } + + // 添加点击事件处理 + // raster.onMouseDown = (event: paper.MouseEvent) => { + // if (currentTool.value === 'reshape') { + // selectPathExclusive(null) // 清除路径选择 + // raster.selected = true + // paper.view.update() + // } + // } + + // 更新视图 + paper.view.update() + // console.log('PNG图片已导入到画布') + } + + raster.onError = () => { + console.error('图片加载失败') + } +} + +// 导入SVG到画布并转换为可编辑的路径 +const importSvgToCanvas = (svgContent: string): void => { + if (!paper.project) return + + try { + // 创建一个临时的SVG元素来解析SVG内容 + const parser = new DOMParser() + const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml') + const svgElement = svgDoc.documentElement + + // 检查是否解析成功 + if (svgElement.nodeName !== 'svg') { + console.error('无效的SVG内容') + return + } + + // 使用Paper.js导入SVG + const importedItem = paper.project.importSVG(svgElement as unknown as SVGElement) + + if (importedItem) { + // 设置位置到画布中心 + importedItem.position = paper.view.center + + // 调整大小适应画布 + const bounds = importedItem.bounds + const scale = Math.min( + canvasWidth.value / bounds.width * 0.6, + canvasHeight.value / bounds.height * 0.6 + ) + + if (scale < 1) { + importedItem.scale(scale) + } + + // 收集所有可编辑的路径 + // const collectPaths = (item: paper.Item): void => { + // if (item instanceof paper.Path && item.segments && item.segments.length > 0) { + // // 添加到可编辑路径列表 + // allPaths.value.push(item) + + // // 添加鼠标事件处理 + // item.onMouseDown = (event: paper.MouseEvent) => { + // if (currentTool.value === 'reshape') { + // showControlPoints(item) + // paper.view.update() + // } + // } + // } else if (item instanceof paper.Group || item instanceof paper.CompoundPath) { + // // 递归处理子项 + // if (item.children) { + // item.children.forEach(child => collectPaths(child)) + // } + // } + // } + + // 收集导入的所有路径 + // collectPaths(importedItem) + + // 更新视图 + paper.view.update() + // console.log(`SVG已导入到画布,共${allPaths.value.length - (allPaths.value.length - countNewPaths(importedItem))}条可编辑路径`) + } + } catch (error) { + console.error('SVG导入失败:', error) + } +} + +// 辅助函数:计算新导入的路径数量 +// const countNewPaths = (item: paper.Item): number => { +// let count = 0 + +// const countPathsRecursive = (item: paper.Item): void => { +// if (item instanceof paper.Path && item.segments && item.segments.length > 0) { +// count++ +// } else if (item instanceof paper.Group || item instanceof paper.CompoundPath) { +// if (item.children) { +// item.children.forEach(child => countPathsRecursive(child)) +// } +// } +// } + +// countPathsRecursive(item) +// return count +// } + + + // 清空画布 const clearCanvas = (): void => { hideControlPoints() @@ -755,6 +985,18 @@ onMounted(() => { color: #f57c00; } +.tool-btn.ai-btn { + background-color: #f3e5f5; + border-color: #9c27b0; + color: #9c27b0; +} + +.tool-btn.ai-btn:hover { + background-color: #e1bee7; + border-color: #7b1fa2; + color: #7b1fa2; +} + .tool-btn svg { flex-shrink: 0; } From 04165af7778dee0aeb8647e69d4de5ab9a260619 Mon Sep 17 00:00:00 2001 From: AvenJi0320 Date: Wed, 13 Aug 2025 16:45:13 +0800 Subject: [PATCH 12/49] feat(spx-gui):add modify privew & change img save func --- .../editor/common/painter/painter.vue | 177 ++++++++++++++---- .../editor/sprite/CostumeDetail.vue | 32 +++- spx-gui/src/components/ui/UIImg.vue | 17 +- 3 files changed, 181 insertions(+), 45 deletions(-) diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index debafe74b..6f6723ebb 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -154,6 +154,10 @@ const imgLoading = ref(true) // 存储背景图片的引用 const backgroundImage = ref(null) +// 背景矩形(用于导出时隐藏) +const backgroundRect = ref(null) +// 标记:当前是否由 props.imgSrc 触发的导入过程(用于避免导入→导出→再次导入循环) +const isImportingFromProps = ref(false) // AI生成弹窗状态 const aiDialogVisible = ref(false) @@ -163,6 +167,10 @@ const props = defineProps<{ imgLoading: boolean }>() +const emit = defineEmits<{ + (e: 'svg-change', svg: string): void +}>() + // 选中路径(独占选择) const selectPathExclusive = (path: paper.Path | null): void => { allPaths.value.forEach((p: paper.Path) => { @@ -173,7 +181,7 @@ const selectPathExclusive = (path: paper.Path | null): void => { } } -// 加载图片到画布 +// 加载位图图片到画布(PNG/JPG/...) const loadImageToCanvas = (imageSrc: string): void => { if (!paper.project) return @@ -190,15 +198,8 @@ const loadImageToCanvas = (imageSrc: string): void => { // 设置图片位置到画布中心 raster.position = paper.view.center - // 可选:调整图片大小适应画布 - const scale = Math.min( - canvasWidth.value / raster.width, - canvasHeight.value / raster.height - ) * 0.8 // 留一些边距 - - if (scale < 1) { - raster.scale(scale) - } + // 保持原始尺寸,不进行自动缩放 + // 如果需要缩放,用户可以手动调整 // 将图片放到最底层,作为背景 raster.sendToBack() @@ -219,6 +220,53 @@ const loadImageToCanvas = (imageSrc: string): void => { // } } +// 根据 url 自动判断并导入到画布(优先解析为 SVG,其次作为位图 Raster) +const loadFileToCanvas = async (imageSrc: string): Promise => { + if (!paper.project) return + try { + const resp = await fetch(imageSrc) + // 先尝试从响应体的 blob.type 判断(对 blob: URL 更可靠) + let isSvg = false + let svgText: string | null = null + try { + const blob = await resp.clone().blob() + if (blob && typeof blob.type === 'string' && blob.type.includes('image/svg')) { + isSvg = true + svgText = await blob.text() + } + } catch {} + + // 退化到 header 判断 + if (!isSvg) { + const contentType = resp.headers.get('content-type') || '' + if (contentType.includes('image/svg')) { + isSvg = true + svgText = await resp.clone().text() + } + } + + // 最后尝试直接将文本解析为 SVG(针对部分 blob: 无类型场景) + if (!isSvg) { + try { + const text = await resp.clone().text() + if (/^\s*\s*$/i.test(text)) { + isSvg = true + svgText = text + } + } catch {} + } + + if (isSvg && svgText != null) { + importSvgToCanvas(svgText) + } else { + loadImageToCanvas(imageSrc) + } + } catch { + // 回退策略:按位图处理 + loadImageToCanvas(imageSrc) + } +} + // 初始化 Paper.js const initPaper = (): void => { if (!canvasRef.value) return @@ -231,11 +279,9 @@ const initPaper = (): void => { size: [canvasWidth.value, canvasHeight.value], fillColor: 'transparent' }) + backgroundRect.value = background - // 如果有初始图片,加载它 - if (props.imgSrc) { - loadImageToCanvas(props.imgSrc) - } + // 初始图片加载交由下面的 watch 处理 paper.view.update() } @@ -259,14 +305,18 @@ const selectTool = (tool: ToolType): void => { // 处理直线创建 const handleLineCreated = (line: paper.Path): void => { + console.log('handleLineCreated 被调用') allPaths.value.push(line) paper.view.update() + exportSvgAndEmit() } // 处理笔刷路径创建 const handlePathCreated = (path: paper.Path): void => { + console.log('handlePathCreated 被调用') allPaths.value.push(path) paper.view.update() + exportSvgAndEmit() } // 创建控制点 @@ -648,6 +698,7 @@ const handleCanvasMouseUp = (event: MouseEvent): void => { // 鼠标释放事件(用于结束拖拽) const handleMouseUp = (): void => { const prevSelected = selectedPoint.value + const wasDragging = isDragging.value if (isDragging.value) { isDragging.value = false selectedPoint.value = null @@ -673,6 +724,16 @@ const handleMouseUp = (): void => { } paper.view.update() } + + if (wasDragging) { + console.log('拖拽结束,准备导出 SVG') + // props 驱动的导入不触发导出,避免循环 + if (!isImportingFromProps.value) exportSvgAndEmit() + else { + console.log('跳过导出(props导入中)') + isImportingFromProps.value = false + } + } } // 显示AI生成弹窗 @@ -718,15 +779,8 @@ const importImageToCanvas = (imageUrl: string): void => { // 设置图片位置到画布中心 raster.position = paper.view.center - // 调整图片大小适应画布 - const scale = Math.min( - canvasWidth.value / raster.width * 0.6, - canvasHeight.value / raster.height * 0.6 - ) - - if (scale < 1) { - raster.scale(scale) - } + // 保持原始尺寸,不进行自动缩放 + // 如果需要缩放,用户可以手动调整 // 添加点击事件处理 // raster.onMouseDown = (event: paper.MouseEvent) => { @@ -740,6 +794,8 @@ const importImageToCanvas = (imageUrl: string): void => { // 更新视图 paper.view.update() // console.log('PNG图片已导入到画布') + + exportSvgAndEmit() } raster.onError = () => { @@ -770,16 +826,8 @@ const importSvgToCanvas = (svgContent: string): void => { // 设置位置到画布中心 importedItem.position = paper.view.center - // 调整大小适应画布 - const bounds = importedItem.bounds - const scale = Math.min( - canvasWidth.value / bounds.width * 0.6, - canvasHeight.value / bounds.height * 0.6 - ) - - if (scale < 1) { - importedItem.scale(scale) - } + // 保持原始尺寸,不进行自动缩放 + // 如果需要缩放,用户可以手动调整 // 收集所有可编辑的路径 // const collectPaths = (item: paper.Item): void => { @@ -808,6 +856,9 @@ const importSvgToCanvas = (svgContent: string): void => { // 更新视图 paper.view.update() // console.log(`SVG已导入到画布,共${allPaths.value.length - (allPaths.value.length - countNewPaths(importedItem))}条可编辑路径`) + // 导入来源于 props 时不导出,避免循环 + if (!isImportingFromProps.value) exportSvgAndEmit() + else isImportingFromProps.value = false } } catch (error) { console.error('SVG导入失败:', error) @@ -862,16 +913,37 @@ const clearCanvas = (): void => { size: [canvasWidth.value, canvasHeight.value], fillColor: 'transparent' }) + backgroundRect.value = background paper.view.update() + + // 导出变更 + exportSvgAndEmit() } // 监听props中的imgSrc变化 -watch(() => props.imgSrc, (newImgSrc) => { - if (newImgSrc) { - loadImageToCanvas(newImgSrc) - } -}, { immediate: true }) +watch( + () => props.imgSrc, + (newImgSrc) => { + if (!newImgSrc) return + isImportingFromProps.value = true + // 每次外部传入图片时,清空当前项目并重新导入,避免叠加 + if (paper.project) { + paper.project.clear() + const background = new paper.Path.Rectangle({ + point: [0, 0], + size: [canvasWidth.value, canvasHeight.value], + fillColor: 'transparent' + }) + backgroundRect.value = background + allPaths.value = [] + backgroundImage.value = null + paper.view.update() + } + loadFileToCanvas(newImgSrc) + }, + { immediate: true } +) onMounted(() => { initPaper() @@ -895,6 +967,7 @@ onMounted(() => { allPaths.value = allPaths.value.filter((path: paper.Path) => path !== pathToDelete) hideControlPoints() paper.view.update() + exportSvgAndEmit() } } } @@ -909,6 +982,34 @@ onMounted(() => { window.removeEventListener('mouseup', handleMouseUp) }) }) + +// 导出当前画布为 SVG 并上报父组件 +const exportSvgAndEmit = (): void => { + console.log('exportSvgAndEmit 被调用') + if (!paper.project) { + console.log('paper.project 不存在') + return + } + const prevVisible = backgroundRect.value?.visible ?? true + if (backgroundRect.value) backgroundRect.value.visible = false + try { + // 导出SVG时保持原始尺寸和viewBox + const svgStr = (paper.project as any).exportSVG({ + asString: true, + embedImages: true, + bounds: paper.view.bounds // 使用视图边界确保尺寸正确 + }) as string + console.log('导出的 SVG 长度:', svgStr?.length) + if (typeof svgStr === 'string') { + console.log('发送 svg-change 事件') + emit('svg-change', svgStr) + } + } catch (e) { + console.error('导出 SVG 失败:', e) + } finally { + if (backgroundRect.value) backgroundRect.value.visible = prevVisible + } +} From d352e46312d828b323ecba6bdec26484441214d2 Mon Sep 17 00:00:00 2001 From: AvenJi0320 Date: Wed, 13 Aug 2025 17:20:12 +0800 Subject: [PATCH 13/49] fix(painter):fix control dot display --- .../editor/common/painter/painter.vue | 78 ++++++++++++------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index 6f6723ebb..eb621b132 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -158,6 +158,8 @@ const backgroundImage = ref(null) const backgroundRect = ref(null) // 标记:当前是否由 props.imgSrc 触发的导入过程(用于避免导入→导出→再次导入循环) const isImportingFromProps = ref(false) +// 保存当前选中的路径,用于导入后恢复控制点显示 +const selectedPathForRestore = ref(null) // AI生成弹窗状态 const aiDialogVisible = ref(false) @@ -300,12 +302,12 @@ const selectTool = (tool: ToolType): void => { drawBrushRef.value.resetDrawing() } + // 切换工具时总是隐藏控制点 hideControlPoints() } // 处理直线创建 const handleLineCreated = (line: paper.Path): void => { - console.log('handleLineCreated 被调用') allPaths.value.push(line) paper.view.update() exportSvgAndEmit() @@ -313,7 +315,6 @@ const handleLineCreated = (line: paper.Path): void => { // 处理笔刷路径创建 const handlePathCreated = (path: paper.Path): void => { - console.log('handlePathCreated 被调用') allPaths.value.push(path) paper.view.update() exportSvgAndEmit() @@ -395,6 +396,8 @@ const hideControlPoints = (): void => { if (paper.project) { paper.project.deselectAll() } + // 强制更新视图 + paper.view.update() } // 检测点击的路径 @@ -596,7 +599,6 @@ const handleCanvasClick = (event: MouseEvent): void => { // 检查是否点击了控制点(优先级最高) const controlPoint = getControlPointAtPoint(point) if (controlPoint) { - // console.log('controlPoint', controlPoint) // 不在这里设置拖拽状态,由 mousedown 处理 return } @@ -604,7 +606,6 @@ const handleCanvasClick = (event: MouseEvent): void => { // 检查是否点击了现有路径 const clickedPath = getPathAtPoint(point) if (clickedPath) { - // console.log('clickedPath', clickedPath) // 仅选中并显示端点(不新增控制点) showControlPoints(clickedPath) paper.view.update() @@ -612,7 +613,6 @@ const handleCanvasClick = (event: MouseEvent): void => { } // 点击空白区域,隐藏控制点 - // console.log('hideControlPoints') hideControlPoints() paper.view.update() } @@ -726,11 +726,9 @@ const handleMouseUp = (): void => { } if (wasDragging) { - console.log('拖拽结束,准备导出 SVG') // props 驱动的导入不触发导出,避免循环 if (!isImportingFromProps.value) exportSvgAndEmit() else { - console.log('跳过导出(props导入中)') isImportingFromProps.value = false } } @@ -830,28 +828,28 @@ const importSvgToCanvas = (svgContent: string): void => { // 如果需要缩放,用户可以手动调整 // 收集所有可编辑的路径 - // const collectPaths = (item: paper.Item): void => { - // if (item instanceof paper.Path && item.segments && item.segments.length > 0) { - // // 添加到可编辑路径列表 - // allPaths.value.push(item) + const collectPaths = (item: paper.Item): void => { + if (item instanceof paper.Path && item.segments && item.segments.length > 0) { + // 添加到可编辑路径列表 + allPaths.value.push(item) - // // 添加鼠标事件处理 - // item.onMouseDown = (event: paper.MouseEvent) => { - // if (currentTool.value === 'reshape') { - // showControlPoints(item) - // paper.view.update() - // } - // } - // } else if (item instanceof paper.Group || item instanceof paper.CompoundPath) { - // // 递归处理子项 - // if (item.children) { - // item.children.forEach(child => collectPaths(child)) - // } - // } - // } + // 添加鼠标事件处理 + item.onMouseDown = (event: paper.MouseEvent) => { + if (currentTool.value === 'reshape') { + showControlPoints(item) + paper.view.update() + } + } + } else if (item instanceof paper.Group || item instanceof paper.CompoundPath) { + // 递归处理子项 + if (item.children) { + item.children.forEach(child => collectPaths(child)) + } + } + } // 收集导入的所有路径 - // collectPaths(importedItem) + collectPaths(importedItem) // 更新视图 paper.view.update() @@ -895,6 +893,9 @@ const clearCanvas = (): void => { }) allPaths.value = [] + // 清理选中路径状态 + selectedPathForRestore.value = null + // 重置直线绘制状态 if (drawLineRef.value) { drawLineRef.value.resetDrawing() @@ -924,9 +925,10 @@ const clearCanvas = (): void => { // 监听props中的imgSrc变化 watch( () => props.imgSrc, - (newImgSrc) => { + async (newImgSrc) => { if (!newImgSrc) return isImportingFromProps.value = true + // 每次外部传入图片时,清空当前项目并重新导入,避免叠加 if (paper.project) { paper.project.clear() @@ -940,7 +942,16 @@ watch( backgroundImage.value = null paper.view.update() } - loadFileToCanvas(newImgSrc) + + // 加载新内容 + await loadFileToCanvas(newImgSrc) + + // 导入完成后清理状态,不自动恢复控制点显示 + // 让用户主动点击路径来显示控制点,避免干扰绘制体验 + setTimeout(() => { + selectedPathForRestore.value = null + isImportingFromProps.value = false + }, 200) }, { immediate: true } ) @@ -990,6 +1001,17 @@ const exportSvgAndEmit = (): void => { console.log('paper.project 不存在') return } + + // 保存当前选中的路径(如果有控制点显示) + if (controlPoints.value.length > 0 && controlPoints.value[0].parentPath) { + selectedPathForRestore.value = controlPoints.value[0].parentPath + } else { + selectedPathForRestore.value = null + } + + // 在导出SVG之前隐藏所有控制点,避免控制点被包含在SVG中 + hideControlPoints() + const prevVisible = backgroundRect.value?.visible ?? true if (backgroundRect.value) backgroundRect.value.visible = false try { From cf0f1aad1140e1d23abe00749b3e67c9c8922a93 Mon Sep 17 00:00:00 2001 From: petezhuang Date: Wed, 13 Aug 2025 17:41:42 +0800 Subject: [PATCH 14/49] feat(spx-gui):(in developing)ai generate png and svg function(backend deployed on localhost) --- spx-gui/src/apis/picgc.ts | 139 +++++++ .../common/painter/components/ai_generate.vue | 388 +++--------------- 2 files changed, 199 insertions(+), 328 deletions(-) diff --git a/spx-gui/src/apis/picgc.ts b/spx-gui/src/apis/picgc.ts index e69de29bb..5b2a9c82e 100644 --- a/spx-gui/src/apis/picgc.ts +++ b/spx-gui/src/apis/picgc.ts @@ -0,0 +1,139 @@ +/** + * @desc Picture Generation APIs for AI image generation + */ + +// independent baseUrl +const PICGC_BASE_URL = 'http://localhost:8080' + +// 简单的HTTP请求函数 +async function picgcRequest(path: string, options: RequestInit = {}) { + const url = PICGC_BASE_URL + path + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Request failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + return response.json() +} + +/** Image generation model types */ +export type ImageModel = 'png' | 'svg' + +/** 可用的样式选项 */ +export type StyleOption = + | 'FLAT_VECTOR' + | 'FLAT_VECTOR_OUTLINE' + | 'FLAT_VECTOR_SILHOUETTE' + | 'FLAT_VECTOR_ONE_LINE_ART' + | 'FLAT_VECTOR_LINE_ART' + +/** Request payload for image generation */ +export interface GenerateImageRequest { + /** The text prompt describing the desired image */ + prompt: string + negative_prompt?: string + style?: StyleOption + format?: string +} + +/** Response from backend API */ +export interface GenerateImageResponse { + id: string + prompt: string + negative_prompt?: string + style?: string + svg_url: string + png_url: string + width: number + height: number + created_at: string +} + + +export async function generateImage( + prompt: string, + options?: { + negative_prompt?: string + style?: StyleOption + format?: string + } +): Promise { + const payload: GenerateImageRequest = { + prompt, + negative_prompt: options?.negative_prompt || 'text, watermark', + style: options?.style || 'FLAT_VECTOR', + format: options?.format + } + + //生成图片的接口 + const response = await picgcRequest('/v1/images', { + method: 'POST', + body: JSON.stringify(payload) + }) as GenerateImageResponse + console.log('response', response) + return response +} + +/** + * 直接生成并返回SVG内容 + */ +export async function generateSvgDirect( + prompt: string, + options?: { + negative_prompt?: string + style?: StyleOption + format?: string + } +): Promise<{ + svgContent: string + id: string + width: number + height: number +}> { + const payload: GenerateImageRequest = { + prompt, + negative_prompt: options?.negative_prompt || 'text, watermark', + style: options?.style || 'FLAT_VECTOR', + format: options?.format + } + + const url = PICGC_BASE_URL + '/v1/images/svg' + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Request failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + // 获取SVG内容 + const svgContent = await response.text() + console.log('svgContent', svgContent) + // 从响应头获取元数据 + const id = response.headers.get('X-Image-Id') || 'unknown' + const width = parseInt(response.headers.get('X-Image-Width') || '512') + const height = parseInt(response.headers.get('X-Image-Height') || '512') + + return { + svgContent, + id, + width, + height + } +} + diff --git a/spx-gui/src/components/editor/common/painter/components/ai_generate.vue b/spx-gui/src/components/editor/common/painter/components/ai_generate.vue index 5517771b3..cec3340dd 100644 --- a/spx-gui/src/components/editor/common/painter/components/ai_generate.vue +++ b/spx-gui/src/components/editor/common/painter/components/ai_generate.vue @@ -136,6 +136,7 @@ diff --git a/spx-gui/src/components/editor/sprite/CostumeItem.vue b/spx-gui/src/components/editor/sprite/CostumeItem.vue index 6e1711014..4bab8af20 100644 --- a/spx-gui/src/components/editor/sprite/CostumeItem.vue +++ b/spx-gui/src/components/editor/sprite/CostumeItem.vue @@ -16,7 +16,7 @@ + + From 760109743b25088c4d7b231e4a2365fe9571010a Mon Sep 17 00:00:00 2001 From: petezhuang Date: Fri, 15 Aug 2025 12:03:03 +0800 Subject: [PATCH 20/49] fix(spx-gui):migrate and decouple ai generate pic and svg function --- .../common/painter/components/ai_generate.vue | 942 ------------------ .../common/painter/components/aigc/error.vue | 316 ++++++ .../painter/components/aigc/generator.vue | 732 ++++++++++++++ .../editor/common/painter/painter.vue | 2 +- 4 files changed, 1049 insertions(+), 943 deletions(-) delete mode 100644 spx-gui/src/components/editor/common/painter/components/ai_generate.vue diff --git a/spx-gui/src/components/editor/common/painter/components/ai_generate.vue b/spx-gui/src/components/editor/common/painter/components/ai_generate.vue deleted file mode 100644 index 1b0af2458..000000000 --- a/spx-gui/src/components/editor/common/painter/components/ai_generate.vue +++ /dev/null @@ -1,942 +0,0 @@ - - - - - diff --git a/spx-gui/src/components/editor/common/painter/components/aigc/error.vue b/spx-gui/src/components/editor/common/painter/components/aigc/error.vue index e69de29bb..858233d98 100644 --- a/spx-gui/src/components/editor/common/painter/components/aigc/error.vue +++ b/spx-gui/src/components/editor/common/painter/components/aigc/error.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/spx-gui/src/components/editor/common/painter/components/aigc/generator.vue b/spx-gui/src/components/editor/common/painter/components/aigc/generator.vue index e69de29bb..0deb543cb 100644 --- a/spx-gui/src/components/editor/common/painter/components/aigc/generator.vue +++ b/spx-gui/src/components/editor/common/painter/components/aigc/generator.vue @@ -0,0 +1,732 @@ + + + + + + \ No newline at end of file diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index 062f316b4..ecaf73d75 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -115,7 +115,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue' import paper from 'paper' import DrawLine from './components/draw_line.vue' import DrawBrush from './components/draw_brush.vue' -import AiGenerate from './components/ai_generate.vue' +import AiGenerate from './components/aigc/generator.vue' import { useImageLoader } from './utils/loader.vue' // 工具类型 From df7ceabaffa7210610f57f48720164a609592020 Mon Sep 17 00:00:00 2001 From: AvenJi0320 Date: Fri, 15 Aug 2025 13:44:52 +0800 Subject: [PATCH 21/49] refactor(painter):extract reshape tool into standalone component --- .../common/painter/components/reshape.vue | 397 ++++++++++ .../editor/common/painter/painter.vue | 695 ++++++------------ 2 files changed, 623 insertions(+), 469 deletions(-) diff --git a/spx-gui/src/components/editor/common/painter/components/reshape.vue b/spx-gui/src/components/editor/common/painter/components/reshape.vue index e69de29bb..01b7dfbdd 100644 --- a/spx-gui/src/components/editor/common/painter/components/reshape.vue +++ b/spx-gui/src/components/editor/common/painter/components/reshape.vue @@ -0,0 +1,397 @@ + + + \ No newline at end of file diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index ecaf73d75..620beb28a 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -99,6 +99,15 @@ :is-active="currentTool === 'brush'" @path-created="handlePathCreated" /> + + + @@ -115,24 +124,12 @@ import { ref, onMounted, onUnmounted, watch } from 'vue' import paper from 'paper' import DrawLine from './components/draw_line.vue' import DrawBrush from './components/draw_brush.vue' +import Reshape from './components/reshape.vue' import AiGenerate from './components/aigc/generator.vue' -import { useImageLoader } from './utils/loader.vue' // 工具类型 type ToolType = 'line' | 'brush' | 'reshape' -// TypeScript 接口定义 -interface ExtendedItem extends paper.Item { - isControlPoint?: boolean - isTemporaryControlPoint?: boolean - segmentIndex?: number - parentPath?: paper.Path -} - -interface Point { - x: number - y: number -} // 响应式变量 const canvasRef = ref(null) @@ -143,15 +140,9 @@ const canvasHeight = ref(600) const currentTool = ref(null) const drawLineRef = ref | null>(null) const drawBrushRef = ref | null>(null) +const reshapeRef = ref | null>(null) // 状态管理 -const isDragging = ref(false) -const selectedPoint = ref(null) const allPaths = ref([]) -const controlPoints = ref([]) -const mouseDownPath = ref(null) -const mouseDownPos = ref(null) -const imgSrc = ref(null) -const imgLoading = ref(true) // 存储背景图片的引用 const backgroundImage = ref(null) @@ -167,9 +158,6 @@ const selectedPathForRestore = ref(null) // AI生成弹窗状态 const aiDialogVisible = ref(false) -// 初始化图片加载器 -const { loadFileToCanvas, importSvgToCanvas, loadImageToCanvas, removeBackgroundImage } = useImageLoader() - const props = defineProps<{ imgSrc: string | null imgLoading: boolean @@ -179,19 +167,86 @@ const emit = defineEmits<{ (e: 'svg-change', svg: string): void }>() -// 选中路径(独占选择) -const selectPathExclusive = (path: paper.Path | null): void => { - allPaths.value.forEach((p: paper.Path) => { - p.selected = false - }) - if (path) { - path.selected = true + +// 加载位图图片到画布(PNG/JPG/...) +const loadImageToCanvas = (imageSrc: string): void => { + if (!paper.project) return + + // 如果已有背景图片,先移除 + if (backgroundImage.value) { + backgroundImage.value.remove() } -} + + // 创建新的光栅图像 + const raster = new paper.Raster(imageSrc) + + raster.onLoad = () => { + raster.position = paper.view.center + + + // 将图片放到最底层,作为背景 + raster.sendToBack() + + backgroundImage.value = raster + paper.view.update() + } + // raster.onMouseDown = (event: paper.MouseEvent) => { + // if (currentTool.value === 'reshape') { + // selectPathExclusive(null) // 清除路径选择 + // raster.selected = true + // paper.view.update() + // } + // } +} +// 根据 url 自动判断并导入到画布(优先解析为 SVG,其次作为位图 Raster) +const loadFileToCanvas = async (imageSrc: string): Promise => { + if (!paper.project) return + try { + const resp = await fetch(imageSrc) + // 先尝试从响应体的 blob.type 判断(对 blob: URL 更可靠) + let isSvg = false + let svgText: string | null = null + try { + const blob = await resp.clone().blob() + if (blob && typeof blob.type === 'string' && blob.type.includes('image/svg')) { + isSvg = true + svgText = await blob.text() + } + } catch {} + + // 退化到 header 判断 + if (!isSvg) { + const contentType = resp.headers.get('content-type') || '' + if (contentType.includes('image/svg')) { + isSvg = true + svgText = await resp.clone().text() + } + } + // 最后尝试直接将文本解析为 SVG(针对部分 blob: 无类型场景) + if (!isSvg) { + try { + const text = await resp.clone().text() + if (/^\s*\s*$/i.test(text)) { + isSvg = true + svgText = text + } + } catch {} + } + + if (isSvg && svgText != null) { + importSvgToCanvas(svgText) + } else { + loadImageToCanvas(imageSrc) + } + } catch { + // 回退策略:按位图处理 + loadImageToCanvas(imageSrc) + } +} // 初始化 Paper.js const initPaper = (): void => { @@ -227,7 +282,9 @@ const selectTool = (tool: ToolType): void => { } // 切换工具时总是隐藏控制点 - hideControlPoints() + if (reshapeRef.value) { + reshapeRef.value.hideControlPoints() + } } // 处理直线创建 @@ -244,231 +301,14 @@ const handlePathCreated = (path: paper.Path): void => { exportSvgAndEmit() } -// 创建控制点 -const createControlPoint = (position: paper.Point): ExtendedItem => { - const point = new paper.Path.Circle({ - center: position, - radius: 8, - fillColor: '#ff4444', - strokeColor: '#cc0000', - strokeWidth: 2 - }) as ExtendedItem - - point.isControlPoint = true - - // 添加悬停效果 - point.onMouseEnter = () => { - point.fillColor = new paper.Color('#ff6666') - point.scale(1.25) // 放大控制点 - paper.view.update() - } - - point.onMouseLeave = () => { - point.fillColor = new paper.Color('#ff4444') - point.scale(0.8) // 恢复原始大小 - paper.view.update() - } - - return point -} - -// 显示路径的控制点 -const showControlPoints = (path: paper.Path): void => { - hideControlPoints() - selectPathExclusive(path) - - // console.log('显示控制点 - 路径类型:', path.constructor.name, '路径:', path) - - if (path && path.segments) { - // console.log('路径段数:', path.segments.length) - path.segments.forEach((segment: paper.Segment, index: number) => { - const controlPoint = createControlPoint(segment.point) - controlPoint.segmentIndex = index - controlPoint.parentPath = path - controlPoints.value.push(controlPoint) - }) - } else if (path instanceof paper.CompoundPath) { - // 处理复合路径 - // console.log('处理复合路径') - path.children.forEach((child: paper.Item, childIndex: number) => { - if (child instanceof paper.Path && child.segments) { - child.segments.forEach((segment: paper.Segment, segIndex: number) => { - const controlPoint = createControlPoint(segment.point) - controlPoint.segmentIndex = segIndex - controlPoint.parentPath = child as paper.Path - controlPoints.value.push(controlPoint) - }) - } - }) - } else { - // console.warn('路径对象没有segments属性:', path) - } -} - -// 隐藏控制点 -const hideControlPoints = (): void => { - controlPoints.value.forEach((point: ExtendedItem) => { - if (point && point.parent) { - point.remove() - } - }) - controlPoints.value = [] - // 同时取消路径高亮 - allPaths.value.forEach((p: paper.Path) => { - p.selected = false - }) - if (paper.project) { - paper.project.deselectAll() - } - // 强制更新视图 - paper.view.update() -} - -// 检测点击的路径 -const getPathAtPoint = (point: paper.Point): paper.Path | null => { - // 扩大检测范围,同时检测填充和描边 - const hitResult = paper.project.hitTest(point, { - segments: false, - stroke: true, - fill: true, // 也检测填充区域 - tolerance: 15 // 增加容差 - }) - - if (hitResult && hitResult.item && !(hitResult.item as ExtendedItem).isControlPoint) { - let targetItem = hitResult.item - - // 如果点击的是组内的对象,需要找到实际的路径对象 - while (targetItem.parent && targetItem.parent !== paper.project.activeLayer) { - targetItem = targetItem.parent - } - - // 检查是否在allPaths数组中 - const pathInArray = allPaths.value.find(path => path === targetItem || path === hitResult.item) - if (pathInArray) { - return pathInArray - } - - // 如果是Path或CompoundPath类型,返回原始点击的对象 - if (hitResult.item instanceof paper.Path || hitResult.item instanceof paper.CompoundPath) { - return hitResult.item as paper.Path - } - } - return null +// 处理路径更新(由reshape组件触发) +const handlePathsUpdate = (paths: paper.Path[]): void => { + allPaths.value = paths } -// 检测点击的控制点 -const getControlPointAtPoint = (point: paper.Point): ExtendedItem | null => { - const hitResult = paper.project.hitTest(point, { - segments: false, - stroke: false, - fill: true, - tolerance: 15 - }) - - if (hitResult && hitResult.item && (hitResult.item as ExtendedItem).isControlPoint) { - return hitResult.item as ExtendedItem - } - return null -} -// 局部平滑函数:只影响当前点及其相邻的线段 -const smoothLocalSegments = (path: paper.Path, segmentIndex: number): void => { - const segments = path.segments - const currentSegment = segments[segmentIndex] - - if (!currentSegment) return - - // 计算影响范围:前一个点到后一个点 - const prevIndex = Math.max(0, segmentIndex - 1) - const nextIndex = Math.min(segments.length - 1, segmentIndex + 1) - - // 如果是端点,使用不同的处理方式 - if (segmentIndex === 0 || segmentIndex === segments.length - 1) { - // 端点:只影响连接的那一段 - if (segmentIndex === 0 && segments.length > 1) { - // 起始端点 - const nextSeg = segments[1] - calculateLocalHandles(currentSegment, nextSeg, null) - } else if (segmentIndex === segments.length - 1 && segments.length > 1) { - // 结束端点 - const prevSeg = segments[segments.length - 2] - calculateLocalHandles(currentSegment, null, prevSeg) - } - } else { - // 中间点:影响前后两段 - const prevSeg = segments[prevIndex] - const nextSeg = segments[nextIndex] - calculateLocalHandles(currentSegment, nextSeg, prevSeg) - } -} - -// 计算局部贝塞尔曲线控制点 -const calculateLocalHandles = ( - currentSeg: paper.Segment, - nextSeg: paper.Segment | null, - prevSeg: paper.Segment | null -): void => { - // 清除现有的控制点 - currentSeg.clearHandles() - - if (nextSeg && prevSeg) { - // 中间点:计算平滑的控制点 - const vector1 = currentSeg.point.subtract(prevSeg.point) - const vector2 = nextSeg.point.subtract(currentSeg.point) - - // 计算控制点方向和长度 - const angle1 = vector1.angle - const angle2 = vector2.angle - const avgAngle = (angle1 + angle2) / 2 - - const length1 = vector1.length * 0.3 - const length2 = vector2.length * 0.3 - - // 设置控制点 - currentSeg.handleIn = new paper.Point({ - angle: avgAngle + 180, - length: length1 - }) - currentSeg.handleOut = new paper.Point({ - angle: avgAngle, - length: length2 - }) - } else if (nextSeg) { - // 起始点:只设置出控制点 - const vector = nextSeg.point.subtract(currentSeg.point) - currentSeg.handleOut = vector.multiply(0.3) - } else if (prevSeg) { - // 结束点:只设置入控制点 - const vector = currentSeg.point.subtract(prevSeg.point) - currentSeg.handleIn = vector.multiply(0.3) - } -} - -// 在路径上添加新的控制点,并返回对应的控制点实例 -const addControlPointOnPath = (path: paper.Path, clickPoint: paper.Point): ExtendedItem | null => { - const location = path.getNearestLocation(clickPoint) - if (location) { - // 在最近的位置分割路径,创建新的段点 - const insertIndex = location.index + 1 - path.insert(insertIndex, location.point) - - // 局部平滑新添加的点及其相邻区域 - smoothLocalSegments(path, insertIndex) - - // 重新显示控制点 - showControlPoints(path) - - // 返回新创建段点对应的控制点实例 - const created = controlPoints.value.find((p: ExtendedItem) => p.parentPath === path && p.segmentIndex === insertIndex) || null - paper.view.update() - return created - } - return null -} - -// 鼠标按下事件(变形-用于开始拖拽控制点) +// 鼠标按下事件 const handleMouseDown = (event: MouseEvent): void => { - // console.log('handleMouseDown') const rect = canvasRef.value?.getBoundingClientRect() if (!rect) return @@ -483,25 +323,11 @@ const handleMouseDown = (event: MouseEvent): void => { return } - if (currentTool.value !== 'reshape') return - - // 检查是否点击了控制点 - const controlPoint = getControlPointAtPoint(point) - if (controlPoint) { - isDragging.value = true - selectedPoint.value = controlPoint + // 处理变形工具 + if (currentTool.value === 'reshape' && reshapeRef.value) { + reshapeRef.value.handleMouseDown(point) return } - - // 若未点到控制点,则检测是否按在某个路径上(不立即创建临时点,等拖拽阈值触发) - const clickedPath = getPathAtPoint(point) - if (clickedPath) { - mouseDownPath.value = clickedPath - mouseDownPos.value = point - } else { - mouseDownPath.value = null - mouseDownPos.value = null - } } // 画布点击事件 @@ -520,25 +346,10 @@ const handleCanvasClick = (event: MouseEvent): void => { drawLineRef.value.handleCanvasClick({ x: point.x, y: point.y }) } } else if (currentTool.value === 'reshape') { - // 检查是否点击了控制点(优先级最高) - const controlPoint = getControlPointAtPoint(point) - if (controlPoint) { - // 不在这里设置拖拽状态,由 mousedown 处理 - return - } - - // 检查是否点击了现有路径 - const clickedPath = getPathAtPoint(point) - if (clickedPath) { - // 仅选中并显示端点(不新增控制点) - showControlPoints(clickedPath) - paper.view.update() - return + // 委托给 Reshape 组件处理 + if (reshapeRef.value) { + reshapeRef.value.handleClick(point) } - - // 点击空白区域,隐藏控制点 - hideControlPoints() - paper.view.update() } } @@ -562,43 +373,9 @@ const handleMouseMove = (event: MouseEvent): void => { drawBrushRef.value.handleMouseDrag({ x: point.x, y: point.y }) } - // 若按在路径上且尚未开始拖拽,当移动超过阈值时,创建临时控制点并进入拖拽 - if ( - currentTool.value === 'reshape' && - !isDragging.value && - mouseDownPath.value && - mouseDownPos.value - ) { - const dx = point.x - mouseDownPos.value.x - const dy = point.y - mouseDownPos.value.y - const dist2 = dx * dx + dy * dy - const threshold2 = 3 * 3 - if (dist2 >= threshold2) { - const tempPoint = addControlPointOnPath(mouseDownPath.value, mouseDownPos.value) - if (tempPoint) { - tempPoint.isTemporaryControlPoint = true - isDragging.value = true - selectedPoint.value = tempPoint - } - } - } - - // 拖拽控制点 - if (isDragging.value && selectedPoint.value) { - selectedPoint.value.position = point - - // 更新对应的路径段点 - if (selectedPoint.value.parentPath && selectedPoint.value.segmentIndex !== undefined) { - const segment = selectedPoint.value.parentPath.segments[selectedPoint.value.segmentIndex] - if (segment) { - segment.point = point - - // 局部平滑:只影响相邻的线段 - smoothLocalSegments(selectedPoint.value.parentPath, selectedPoint.value.segmentIndex) - } - } - - paper.view.update() + // 委托给 Reshape 组件处理拖拽 + if (currentTool.value === 'reshape' && reshapeRef.value) { + reshapeRef.value.handleMouseMove(point) } } @@ -619,100 +396,34 @@ const handleCanvasMouseUp = (event: MouseEvent): void => { } } -// 鼠标释放事件(用于结束拖拽) +// 全局鼠标释放事件(用于reshape工具) const handleMouseUp = (): void => { - const prevSelected = selectedPoint.value - const wasDragging = isDragging.value - if (isDragging.value) { - isDragging.value = false - selectedPoint.value = null - } - - // 清理按下状态 - const prevMouseDownPath = mouseDownPath.value - mouseDownPath.value = null - mouseDownPos.value = null - - // 若为临时控制点,则在松开时删除该控制点,并刷新端点显示 - if (prevSelected && (prevSelected as ExtendedItem).isTemporaryControlPoint) { - if (prevSelected.parent) { - prevSelected.remove() - } - controlPoints.value = controlPoints.value.filter((p: ExtendedItem) => p !== prevSelected) - - // 重新显示该路径的所有端点(包含新插入的段点) - if (prevSelected.parentPath) { - showControlPoints(prevSelected.parentPath) - } else if (prevMouseDownPath) { - showControlPoints(prevMouseDownPath) - } - paper.view.update() - } - - if (wasDragging) { - // props 驱动的导入不触发导出,避免循环 - if (!isImportingFromProps.value) exportSvgAndEmit() - else { - isImportingFromProps.value = false - } + if (currentTool.value === 'reshape' && reshapeRef.value) { + reshapeRef.value.handleMouseUp() } } + // 显示AI生成弹窗 const showAiDialog = (): void => { aiDialogVisible.value = true } // 处理AI生成确认 -const handleAiConfirm = async (data: { +const handleAiConfirm = (data: { model: string; prompt: string; url?: string; svgContent?: string; -}): Promise => { +}): void => { // console.log('AI生成确认:', data) - try { - if (data.model === 'svg' && data.svgContent) { - // 处理SVG导入 - await importSvgToCanvas(data.svgContent, { - onPathCreated: (path: paper.Path) => { - // 添加到可编辑路径列表 - allPaths.value.push(path) - - // 添加鼠标事件处理 - path.onMouseDown = (event: paper.MouseEvent) => { - if (currentTool.value === 'reshape') { - showControlPoints(path) - paper.view.update() - } - } - }, - onLoadComplete: () => { - // 导入来源于 props 时不导出,避免循环 - if (!isImportingFromProps.value) exportSvgAndEmit() - else isImportingFromProps.value = false - }, - onError: (error) => { - console.error('Failed to import SVG:', error) - } - }) - } else if (data.model === 'png' && data.url) { - // 处理PNG图片导入 - await loadImageToCanvas(data.url, { - onImageLoaded: (raster: paper.Raster) => { - backgroundImage.value = raster - }, - onLoadComplete: () => { - exportSvgAndEmit() - }, - onError: (error) => { - console.error('Failed to load image:', error) - } - }) - } - } catch (error) { - console.error('AI生成处理失败:', error) + if (data.model === 'svg' && data.svgContent) { + // 处理SVG导入 + importSvgToCanvas(data.svgContent) + } else if (data.model === 'png' && data.url) { + // 处理PNG图片导入 + importImageToCanvas(data.url) } aiDialogVisible.value = false @@ -724,16 +435,102 @@ const handleAiCancel = (): void => { aiDialogVisible.value = false } +// 导入PNG图片到画布 +const importImageToCanvas = (imageUrl: string): void => { + if (!paper.project) return + + const raster = new paper.Raster(imageUrl) + + raster.onLoad = () => { + raster.position = paper.view.center + + // 保持原始尺寸,不进行自动缩放 + // 如果需要缩放,用户可以手动调整 + + // 添加点击事件处理 + // raster.onMouseDown = (event: paper.MouseEvent) => { + // if (currentTool.value === 'reshape') { + // selectPathExclusive(null) // 清除路径选择 + // raster.selected = true + // paper.view.update() + // } + // } + paper.view.update() + // console.log('PNG图片已导入到画布') + exportSvgAndEmit() + } + + raster.onError = () => { + console.error('failed to load image') + } +} - - - +// 导入SVG到画布并转换为可编辑的路径 +const importSvgToCanvas = (svgContent: string): void => { + if (!paper.project) return + + try { + // 创建一个临时的SVG元素来解析SVG内容 + const parser = new DOMParser() + const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml') + const svgElement = svgDoc.documentElement + + // 检查是否解析成功 + if (svgElement.nodeName !== 'svg') { + console.error('invalid svg content') + return + } + + // 使用Paper.js导入SVG + const importedItem = paper.project.importSVG(svgElement as unknown as SVGElement) + + if (importedItem) { + importedItem.position = paper.view.center + + + // 收集所有可编辑的路径 + const collectPaths = (item: paper.Item): void => { + if (item instanceof paper.Path && item.segments && item.segments.length > 0) { + // 添加到可编辑路径列表 + allPaths.value.push(item) + + // 添加鼠标事件处理 + item.onMouseDown = () => { + if (currentTool.value === 'reshape' && reshapeRef.value) { + reshapeRef.value.showControlPoints(item) + paper.view.update() + } + } + } else if (item instanceof paper.Group || item instanceof paper.CompoundPath) { + // 递归处理子项 + if (item.children) { + item.children.forEach(child => collectPaths(child)) + } + } + } + + // 收集导入的所有路径 + collectPaths(importedItem) + + // 更新视图 + paper.view.update() + // console.log(`SVG已导入到画布,共${allPaths.value.length - (allPaths.value.length - countNewPaths(importedItem))}条可编辑路径`) + // 导入来源于 props 时不导出,避免循环 + if (!isImportingFromProps.value) exportSvgAndEmit() + else isImportingFromProps.value = false + } + } catch (error) { + console.error('failed to import svg:', error) + } +} // 清空画布 const clearCanvas = (): void => { - hideControlPoints() + if (reshapeRef.value) { + reshapeRef.value.hideControlPoints() + } allPaths.value.forEach((path: paper.Path) => { if (path && path.parent) { path.remove() @@ -793,52 +590,20 @@ watch( }) backgroundRect.value = background allPaths.value = [] - - // 如果已有背景图片,先移除 - if (backgroundImage.value) { - removeBackgroundImage(backgroundImage.value) - backgroundImage.value = null - } - + backgroundImage.value = null paper.view.update() } - try { - // 加载新内容 - await loadFileToCanvas(newImgSrc, { - onPathCreated: (path: paper.Path) => { - // 添加到可编辑路径列表 - allPaths.value.push(path) - - // 添加鼠标事件处理 - path.onMouseDown = (event: paper.MouseEvent) => { - if (currentTool.value === 'reshape') { - showControlPoints(path) - paper.view.update() - } - } - }, - onImageLoaded: (raster: paper.Raster) => { - backgroundImage.value = raster - }, - onLoadComplete: () => { - // 导入完成后清理状态,不自动恢复控制点显示 - // 让用户主动点击路径来显示控制点,避免干扰绘制体验 - setTimeout(() => { - selectedPathForRestore.value = null - isImportingFromProps.value = false - isFirstMount.value = false // 标记第一次挂载已完成 - }, 200) - }, - onError: (error) => { - console.error('Failed to load file:', error) - isImportingFromProps.value = false - } - }) - } catch (error) { - console.error('Props图片加载失败:', error) + // 加载新内容 + await loadFileToCanvas(newImgSrc) + + // 导入完成后清理状态,不自动恢复控制点显示 + // 让用户主动点击路径来显示控制点,避免干扰绘制体验 + setTimeout(() => { + selectedPathForRestore.value = null isImportingFromProps.value = false - } + isFirstMount.value = false // 标记第一次挂载已完成 + }, 200) }, { immediate: true } ) @@ -849,7 +614,9 @@ onMounted(() => { // 添加键盘事件监听 const handleKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { - hideControlPoints() + if (reshapeRef.value) { + reshapeRef.value.hideControlPoints() + } if (drawLineRef.value) { drawLineRef.value.resetDrawing() } @@ -857,20 +624,15 @@ onMounted(() => { drawBrushRef.value.resetDrawing() } paper.view.update() - } else if (event.key === 'Delete' || event.key === 'Backspace') { - // 删除选中的路径 - if (controlPoints.value.length > 0 && controlPoints.value[0].parentPath) { - const pathToDelete = controlPoints.value[0].parentPath - pathToDelete.remove() - allPaths.value = allPaths.value.filter((path: paper.Path) => path !== pathToDelete) - hideControlPoints() - paper.view.update() - exportSvgAndEmit() + } else { + // 委托给reshape组件处理其他键盘事件 + if (reshapeRef.value) { + reshapeRef.value.handleKeyDown(event) } } } - // 添加鼠标释放事件监听 + // 添加键盘和鼠标事件监听 window.addEventListener('keydown', handleKeyDown) window.addEventListener('mouseup', handleMouseUp) @@ -885,19 +647,14 @@ onMounted(() => { const exportSvgAndEmit = (): void => { // console.log('exportSvgAndEmit 被调用') if (!paper.project) { - console.log('paper.project does not exist') + // paper.project does not exist return } - // 保存当前选中的路径(如果有控制点显示) - if (controlPoints.value.length > 0 && controlPoints.value[0].parentPath) { - selectedPathForRestore.value = controlPoints.value[0].parentPath - } else { - selectedPathForRestore.value = null - } - // 在导出SVG之前隐藏所有控制点,避免控制点被包含在SVG中 - hideControlPoints() + if (reshapeRef.value) { + reshapeRef.value.hideControlPoints() + } const prevVisible = backgroundRect.value?.visible ?? true if (backgroundRect.value) backgroundRect.value.visible = false From 12e19383835de9a1df7af59f5ab09ba6b52281ab Mon Sep 17 00:00:00 2001 From: AvenJi0320 Date: Fri, 15 Aug 2025 15:05:38 +0800 Subject: [PATCH 22/49] feat(painter): add 5 placeholder tools with improved layout --- .../common/painter/components/circle_tool.vue | 15 ++ .../common/painter/components/eraser_tool.vue | 15 ++ .../common/painter/components/fill_tool.vue | 15 ++ .../painter/components/rectangle_tool.vue | 15 ++ .../common/painter/components/text_tool.vue | 15 ++ .../editor/common/painter/painter.vue | 200 ++++++++++++++---- 6 files changed, 236 insertions(+), 39 deletions(-) create mode 100644 spx-gui/src/components/editor/common/painter/components/circle_tool.vue create mode 100644 spx-gui/src/components/editor/common/painter/components/eraser_tool.vue create mode 100644 spx-gui/src/components/editor/common/painter/components/fill_tool.vue create mode 100644 spx-gui/src/components/editor/common/painter/components/rectangle_tool.vue create mode 100644 spx-gui/src/components/editor/common/painter/components/text_tool.vue diff --git a/spx-gui/src/components/editor/common/painter/components/circle_tool.vue b/spx-gui/src/components/editor/common/painter/components/circle_tool.vue new file mode 100644 index 000000000..033e63b27 --- /dev/null +++ b/spx-gui/src/components/editor/common/painter/components/circle_tool.vue @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/spx-gui/src/components/editor/common/painter/components/eraser_tool.vue b/spx-gui/src/components/editor/common/painter/components/eraser_tool.vue new file mode 100644 index 000000000..c10e929a7 --- /dev/null +++ b/spx-gui/src/components/editor/common/painter/components/eraser_tool.vue @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/spx-gui/src/components/editor/common/painter/components/fill_tool.vue b/spx-gui/src/components/editor/common/painter/components/fill_tool.vue new file mode 100644 index 000000000..ac36c9536 --- /dev/null +++ b/spx-gui/src/components/editor/common/painter/components/fill_tool.vue @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/spx-gui/src/components/editor/common/painter/components/rectangle_tool.vue b/spx-gui/src/components/editor/common/painter/components/rectangle_tool.vue new file mode 100644 index 000000000..7a13a80a5 --- /dev/null +++ b/spx-gui/src/components/editor/common/painter/components/rectangle_tool.vue @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/spx-gui/src/components/editor/common/painter/components/text_tool.vue b/spx-gui/src/components/editor/common/painter/components/text_tool.vue new file mode 100644 index 000000000..af27dc491 --- /dev/null +++ b/spx-gui/src/components/editor/common/painter/components/text_tool.vue @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index 620beb28a..5aefbeb96 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -4,40 +4,105 @@

{{ $t({ en: 'Drawing Tools', zh: '绘图工具' }) }}

- - - - - +
+ + + + + + + + + + + + + + + +
@@ -108,6 +173,41 @@ @paths-update="handlePathsUpdate" @svg-export="exportSvgAndEmit" /> + + + + + + + + + + + + + + +
@@ -125,10 +225,15 @@ import paper from 'paper' import DrawLine from './components/draw_line.vue' import DrawBrush from './components/draw_brush.vue' import Reshape from './components/reshape.vue' +import EraserTool from './components/eraser_tool.vue' +import RectangleTool from './components/rectangle_tool.vue' +import CircleTool from './components/circle_tool.vue' +import FillTool from './components/fill_tool.vue' +import TextTool from './components/text_tool.vue' import AiGenerate from './components/aigc/generator.vue' // 工具类型 -type ToolType = 'line' | 'brush' | 'reshape' +type ToolType = 'line' | 'brush' | 'reshape' | 'eraser' | 'rectangle' | 'circle' | 'fill' | 'text' // 响应式变量 @@ -704,6 +809,12 @@ const exportSvgAndEmit = (): void => { gap: 8px; } +.tool-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + .tool-title { font-size: 14px; font-weight: 600; @@ -715,17 +826,20 @@ const exportSvgAndEmit = (): void => { .tool-btn { display: flex; + flex-direction: column; align-items: center; - gap: 8px; - padding: 12px 16px; + justify-content: center; + gap: 4px; + padding: 8px 6px; border: 1px solid #e0e0e0; border-radius: 8px; background-color: #fff; color: #666; cursor: pointer; - font-size: 14px; + font-size: 12px; transition: all 0.2s ease; - text-align: left; + text-align: center; + min-height: 48px; } .tool-btn:hover { @@ -740,6 +854,11 @@ const exportSvgAndEmit = (): void => { color: #2196f3; } +.tool-btn:focus { + outline: none; + box-shadow: none; +} + .tool-btn.action-btn { background-color: #fff3e0; border-color: #ff9800; @@ -770,6 +889,9 @@ const exportSvgAndEmit = (): void => { .tool-btn span { font-weight: 500; + font-size: 11px; + line-height: 1.2; + white-space: nowrap; } /* 画布区域样式 */ From a4d704acc184342b2d25b625ebb67328d5dec793 Mon Sep 17 00:00:00 2001 From: petezhuang Date: Fri, 15 Aug 2025 16:52:32 +0800 Subject: [PATCH 23/49] feat(spx-gui):select different text to svg provider and several bug fixs --- spx-gui/src/apis/picgc.ts | 61 +++++++++++++------ .../common/painter/components/aigc/error.vue | 42 ++++++++----- .../painter/components/aigc/generator.vue | 45 ++++++-------- .../editor/common/painter/painter.vue | 7 ++- 4 files changed, 93 insertions(+), 62 deletions(-) diff --git a/spx-gui/src/apis/picgc.ts b/spx-gui/src/apis/picgc.ts index 5b2a9c82e..1965ee072 100644 --- a/spx-gui/src/apis/picgc.ts +++ b/spx-gui/src/apis/picgc.ts @@ -28,20 +28,11 @@ async function picgcRequest(path: string, options: RequestInit = {}) { /** Image generation model types */ export type ImageModel = 'png' | 'svg' -/** 可用的样式选项 */ -export type StyleOption = - | 'FLAT_VECTOR' - | 'FLAT_VECTOR_OUTLINE' - | 'FLAT_VECTOR_SILHOUETTE' - | 'FLAT_VECTOR_ONE_LINE_ART' - | 'FLAT_VECTOR_LINE_ART' - /** Request payload for image generation */ export interface GenerateImageRequest { /** The text prompt describing the desired image */ prompt: string negative_prompt?: string - style?: StyleOption format?: string } @@ -58,19 +49,17 @@ export interface GenerateImageResponse { created_at: string } - +//todo:弃用 export async function generateImage( prompt: string, options?: { negative_prompt?: string - style?: StyleOption format?: string } ): Promise { const payload: GenerateImageRequest = { prompt, negative_prompt: options?.negative_prompt || 'text, watermark', - style: options?.style || 'FLAT_VECTOR', format: options?.format } @@ -87,10 +76,10 @@ export async function generateImage( * 直接生成并返回SVG内容 */ export async function generateSvgDirect( + provider: string,//通过这个参数选择供应商:claude,recraft prompt: string, options?: { negative_prompt?: string - style?: StyleOption format?: string } ): Promise<{ @@ -102,12 +91,20 @@ export async function generateSvgDirect( const payload: GenerateImageRequest = { prompt, negative_prompt: options?.negative_prompt || 'text, watermark', - style: options?.style || 'FLAT_VECTOR', format: options?.format } - const url = PICGC_BASE_URL + '/v1/images/svg' - + let url = '' + switch (provider) { + case 'claude': + url = PICGC_BASE_URL + '/v1/images/claude/svg' + break + case 'recraft': + url = PICGC_BASE_URL + '/v1/images/recraft/svg' + break + default: + throw new Error('Invalid provider') + } const response = await fetch(url, { method: 'POST', headers: { @@ -126,14 +123,38 @@ export async function generateSvgDirect( console.log('svgContent', svgContent) // 从响应头获取元数据 const id = response.headers.get('X-Image-Id') || 'unknown' - const width = parseInt(response.headers.get('X-Image-Width') || '512') - const height = parseInt(response.headers.get('X-Image-Height') || '512') + const width = parseInt('512') + const height = parseInt('512') + + // 修改SVG内容的尺寸:大小为512*512 + const modifiedSvgContent = svgContent.replace( + /]*?)>/, + (match: string, attributes: string) => { + // 解析现有属性 + let newAttributes = attributes + + // 更新或添加width属性 + if (newAttributes.includes('width=')) { + newAttributes = newAttributes.replace(/width="[^"]*"/, `width="${width}"`) + } else { + newAttributes += ` width="${width}"` + } + + // 更新或添加height属性 + if (newAttributes.includes('height=')) { + newAttributes = newAttributes.replace(/height="[^"]*"/, `height="${height}"`) + } else { + newAttributes += ` height="${height}"` + } + console.log('newAttributes',newAttributes) + return `` + } + ) return { - svgContent, + svgContent: modifiedSvgContent, id, width, height } } - diff --git a/spx-gui/src/components/editor/common/painter/components/aigc/error.vue b/spx-gui/src/components/editor/common/painter/components/aigc/error.vue index 858233d98..979cba801 100644 --- a/spx-gui/src/components/editor/common/painter/components/aigc/error.vue +++ b/spx-gui/src/components/editor/common/painter/components/aigc/error.vue @@ -22,7 +22,7 @@

- {{ getErrorMessage() }} + {{ errorMessage }}

{{ $t({ en: 'Suggestions:', zh: '建议:' }) }}

@@ -55,17 +55,17 @@ + \ No newline at end of file diff --git a/spx-gui/src/components/editor/common/painter/components/reshape.vue b/spx-gui/src/components/editor/common/painter/components/reshape.vue index 01b7dfbdd..10ac52358 100644 --- a/spx-gui/src/components/editor/common/painter/components/reshape.vue +++ b/spx-gui/src/components/editor/common/painter/components/reshape.vue @@ -16,12 +16,6 @@ const props = defineProps<{ allPaths: paper.Path[] }>() -// Emits定义 -const emit = defineEmits<{ - (e: 'paths-update', paths: paper.Path[]): void - (e: 'svg-export'): void -}>() - // 状态管理 const isDragging = ref(false) const selectedPoint = ref(null) @@ -29,6 +23,13 @@ const controlPoints = ref([]) const mouseDownPath = ref(null) const mouseDownPos = ref(null) +//注入父组件接口 +import { inject, type Ref } from 'vue' + +const getAllPathsValue = inject<() => paper.Path[]>('getAllPathsValue')! +const setAllPathsValue = inject<(paths: paper.Path[]) => void>('setAllPathsValue')! +const exportSvgAndEmit = inject<() => void>('exportSvgAndEmit')! + // 选中路径(独占选择) const selectPathExclusive = (path: paper.Path | null): void => { props.allPaths.forEach((p: paper.Path) => { @@ -322,7 +323,8 @@ const handleMouseUp = (): void => { } if (wasDragging) { - emit('svg-export') + // 使用注入的接口而不是事件上报 + exportSvgAndEmit() } } @@ -351,11 +353,16 @@ const deleteSelectedPath = (): void => { if (controlPoints.value.length > 0 && controlPoints.value[0].parentPath) { const pathToDelete = controlPoints.value[0].parentPath pathToDelete.remove() - const updatedPaths = props.allPaths.filter((path: paper.Path) => path !== pathToDelete) + + // 使用注入的接口更新路径数组 + const updatedPaths = getAllPathsValue().filter((path: paper.Path) => path !== pathToDelete) + setAllPathsValue(updatedPaths) + hideControlPoints() paper.view.update() - emit('paths-update', updatedPaths) - emit('svg-export') + + // 使用注入的接口而不是事件上报 + exportSvgAndEmit() } } diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index 51eebf5fb..8074005d3 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -161,7 +161,6 @@ :canvas-width="canvasWidth" :canvas-height="canvasHeight" :is-active="currentTool === 'brush'" - @path-created="handlePathCreated" /> @@ -169,8 +168,6 @@ ref="reshapeRef" :is-active="currentTool === 'reshape'" :all-paths="allPaths" - @paths-update="handlePathsUpdate" - @svg-export="exportSvgAndEmit" /> @@ -366,17 +363,6 @@ const setAllPathsValue = (paths: paper.Path[]): void => { provide('getAllPathsValue',getAllPathsValue) provide('setAllPathsValue',setAllPathsValue) -// 处理笔刷路径创建 -const handlePathCreated = (path: paper.Path): void => { - allPaths.value.push(path) - paper.view.update() - exportSvgAndEmit() -} - -// 处理路径更新(由reshape组件触发) -const handlePathsUpdate = (paths: paper.Path[]): void => { - allPaths.value = paths -} // 显示AI生成弹窗 const showAiDialog = (): void => { From a6144a784069974575a8c7d153421cac571459c2 Mon Sep 17 00:00:00 2001 From: AvenJi0320 Date: Mon, 25 Aug 2025 17:23:13 +0800 Subject: [PATCH 42/49] docs(spx-gui): add aigc-design doc --- spx-gui/docs/aigc-design.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 spx-gui/docs/aigc-design.md diff --git a/spx-gui/docs/aigc-design.md b/spx-gui/docs/aigc-design.md new file mode 100644 index 000000000..207a458c3 --- /dev/null +++ b/spx-gui/docs/aigc-design.md @@ -0,0 +1,35 @@ +# AIGC架构设计 +## 一、generator生成器 +生成器是aigc部分的主组件,负责整个ai生成组件的渲染。同时,负责调用封装好的接口,发起与后端的交互。并处理AI生成的相关逻辑。错误处理部分,则通过try-catch调用error.vue中的错误处理逻辑。 +## 二、error.vue 错误处理页面 +error作为错误处理组件,提供接口函数给generator使用。通过调用和参数传递,呼出相应的错误页面,给用户提供相应的错误信息。 +## 三、modelSelector 模型选择器 +主要是一个下拉菜单组件。给用户提供风格等预设,作为发后端的接口参数给generator使用。 +## 四、prompt组织区域 +此区域用于给用户选择提示词输入方式,并完成提示词输入流程。 +1.预设提示词:提供类似“完形填空”的效果。将用户填写完的prompt拼成整个字符串给generator发请求用 +2.自由输入提示词 +## 五、src/apis/picgc.ts api层 +将与后端ai交互的请求封装,供给generator使用 +* 架构设计图 + +```mermaid +flowchart TD + Backend((Backend)) + picgc[picgc.ts] + Generator[Generator] + utils[utils] + Painter((Painter)) + + prompt[prompt input area] + error[Error handling page] + model[model selector] + + Backend <--> picgc + picgc <--> Generator + Generator --> prompt + Generator --> error + Generator --> model + Generator --> utils + utils --> Painter +``` From 168895ba2d5d2e13906401bc8fa4f05a8f8b4b78 Mon Sep 17 00:00:00 2001 From: AvenJi0320 Date: Mon, 25 Aug 2025 17:32:11 +0800 Subject: [PATCH 43/49] docs(spx-gui): add english aigc-design version doc --- spx-gui/docs/aigc-design.md | 29 ++++++++++++++-------------- spx-gui/docs/aigc-design.zh.md | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 spx-gui/docs/aigc-design.zh.md diff --git a/spx-gui/docs/aigc-design.md b/spx-gui/docs/aigc-design.md index 207a458c3..c562c63bb 100644 --- a/spx-gui/docs/aigc-design.md +++ b/spx-gui/docs/aigc-design.md @@ -1,18 +1,17 @@ -# AIGC架构设计 -## 一、generator生成器 -生成器是aigc部分的主组件,负责整个ai生成组件的渲染。同时,负责调用封装好的接口,发起与后端的交互。并处理AI生成的相关逻辑。错误处理部分,则通过try-catch调用error.vue中的错误处理逻辑。 -## 二、error.vue 错误处理页面 -error作为错误处理组件,提供接口函数给generator使用。通过调用和参数传递,呼出相应的错误页面,给用户提供相应的错误信息。 -## 三、modelSelector 模型选择器 -主要是一个下拉菜单组件。给用户提供风格等预设,作为发后端的接口参数给generator使用。 -## 四、prompt组织区域 -此区域用于给用户选择提示词输入方式,并完成提示词输入流程。 -1.预设提示词:提供类似“完形填空”的效果。将用户填写完的prompt拼成整个字符串给generator发请求用 -2.自由输入提示词 -## 五、src/apis/picgc.ts api层 -将与后端ai交互的请求封装,供给generator使用 -* 架构设计图 - +# AIGC Architecture Design +## I. Generator +The generator is the core component of the AIGC module, responsible for rendering the entire AI generation interface. It also invokes encapsulated APIs to initiate interactions with the backend and manages the logic of AI content generation. Error handling is implemented through a try-catch block that triggers the error-handling logic defined in error.vue. +## II. error.vue – Error Handling Page +The error component provides an error-handling interface for the generator. By invoking its functions and passing parameters, the generator can display the appropriate error page and present the user with corresponding error messages. +## III. modelSelector – Model Selector +The model selector is primarily a dropdown menu component. It allows users to choose styles and other presets, which are then passed as parameters to the backend through the generator. +## IV. Prompt Organization Area +This section lets users choose how to input prompts and complete the prompt-entry process: +1. Preset prompts – Provides a “fill-in-the-blank” style interaction. Once completed by the user, the prompt is concatenated into a full string and sent to the generator for requests. +2. Free-form prompt input – Users can directly enter custom prompts. +## V. src/apis/picgc.ts – API Layer +This module encapsulates all backend AI interaction requests and exposes them for use by the generator. +* Architecture Diagram ```mermaid flowchart TD Backend((Backend)) diff --git a/spx-gui/docs/aigc-design.zh.md b/spx-gui/docs/aigc-design.zh.md new file mode 100644 index 000000000..207a458c3 --- /dev/null +++ b/spx-gui/docs/aigc-design.zh.md @@ -0,0 +1,35 @@ +# AIGC架构设计 +## 一、generator生成器 +生成器是aigc部分的主组件,负责整个ai生成组件的渲染。同时,负责调用封装好的接口,发起与后端的交互。并处理AI生成的相关逻辑。错误处理部分,则通过try-catch调用error.vue中的错误处理逻辑。 +## 二、error.vue 错误处理页面 +error作为错误处理组件,提供接口函数给generator使用。通过调用和参数传递,呼出相应的错误页面,给用户提供相应的错误信息。 +## 三、modelSelector 模型选择器 +主要是一个下拉菜单组件。给用户提供风格等预设,作为发后端的接口参数给generator使用。 +## 四、prompt组织区域 +此区域用于给用户选择提示词输入方式,并完成提示词输入流程。 +1.预设提示词:提供类似“完形填空”的效果。将用户填写完的prompt拼成整个字符串给generator发请求用 +2.自由输入提示词 +## 五、src/apis/picgc.ts api层 +将与后端ai交互的请求封装,供给generator使用 +* 架构设计图 + +```mermaid +flowchart TD + Backend((Backend)) + picgc[picgc.ts] + Generator[Generator] + utils[utils] + Painter((Painter)) + + prompt[prompt input area] + error[Error handling page] + model[model selector] + + Backend <--> picgc + picgc <--> Generator + Generator --> prompt + Generator --> error + Generator --> model + Generator --> utils + utils --> Painter +``` From a32a7f0d8f07b159a4b1c757cd67a725e606a929 Mon Sep 17 00:00:00 2001 From: linjc Date: Mon, 25 Aug 2025 17:47:32 +0800 Subject: [PATCH 44/49] docs(spx-backend):add theme model doc --- spx-backend/docs/SVG_THEME_TEMPLATES.md | 430 ++++++++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 spx-backend/docs/SVG_THEME_TEMPLATES.md diff --git a/spx-backend/docs/SVG_THEME_TEMPLATES.md b/spx-backend/docs/SVG_THEME_TEMPLATES.md new file mode 100644 index 000000000..e00b6f09a --- /dev/null +++ b/spx-backend/docs/SVG_THEME_TEMPLATES.md @@ -0,0 +1,430 @@ +# SVG主题提示词模板功能文档 + +## 概述 + +SVG主题提示词模板功能为SVG生成服务提供了智能的主题化增强能力。通过预定义的主题模板,系统能够自动将主题相关的提示词与用户输入进行智能拼接,显著提升生成的SVG图像质量和一致性。 + +## 功能特性 + +1. **智能提示词增强**: 根据不同教育场景和用途提供专门优化的提示词模板 +2. **多语言支持**: 同时支持中英文提示词,自动根据语言偏好选择 +3. **可配置强度**: 支持0-1范围的主题影响强度控制 +4. **灵活定制**: 支持自定义前缀、后缀和风格修饰词 +5. **热更新**: 支持运行时重载主题配置 +6. **向下兼容**: 完全兼容现有API,不影响未启用主题的请求 + +## 架构设计 + +### 包结构 +``` +internal/svggen/theme/ +├── types.go # 主题相关类型定义 +├── templates.go # 内置模板定义 +├── config.go # 配置加载和环境变量处理 +├── manager.go # 主题管理器核心逻辑 +└── manager_test.go # 单元测试 +``` + +### 核心组件 + +#### ThemeManager +- 负责加载和管理主题模板 +- 提供主题查询和提示词拼接功能 +- 支持模板缓存和运行时重载 +- 线程安全的并发访问 + +#### ThemeTemplate +- 定义主题的结构化信息 +- 包含多语言提示词、风格修饰词、质量增强词 +- 支持提供商特定的配置覆盖 + +### 集成方式 + +主题功能通过中间件方式集成到现有的`svggen.ServiceManager`中,在生成请求处理流程中自动进行主题增强: + +``` +用户请求 -> 主题处理 -> 翻译处理 -> 提供商生成 -> 响应 +``` + +## 数据结构 + +### 主题类型定义 + +```go +type ThemeType string + +const ( + ThemeEducationMath ThemeType = "education_math" + ThemeEducationScience ThemeType = "education_science" + ThemeEducationArt ThemeType = "education_art" + ThemeGameCartoon ThemeType = "game_cartoon" + ThemeGamePixel ThemeType = "game_pixel" + ThemeUIIcon ThemeType = "ui_icon" + ThemeUIIllustration ThemeType = "ui_illustration" + ThemeGeneral ThemeType = "general" +) +``` + +### 主题模板结构 + +```go +type ThemeTemplate struct { + Type ThemeType `json:"type"` + Name string `json:"name"` + NameCN string `json:"name_cn"` + Description string `json:"description"` + DescriptionCN string `json:"description_cn"` + + // 提示词模板 + PrefixPrompt string `json:"prefix_prompt"` + PrefixPromptCN string `json:"prefix_prompt_cn"` + SuffixPrompt string `json:"suffix_prompt"` + SuffixPromptCN string `json:"suffix_prompt_cn"` + + // 默认参数 + DefaultStyle string `json:"default_style"` + DefaultNegative string `json:"default_negative"` + DefaultNegativeCN string `json:"default_negative_cn"` + + // 高级配置 + StyleModifiers []string `json:"style_modifiers"` + QualityEnhancers []string `json:"quality_enhancers"` + + // 提供商特定配置 + ProviderOverrides map[string]ProviderConfig `json:"provider_overrides"` +} +``` + +### API参数扩展 + +现有的`GenerateRequest`和控制器参数已扩展支持主题字段: + +```go +type GenerateRequest struct { + // 现有字段... + Prompt string `json:"prompt"` + NegativePrompt string `json:"negative_prompt,omitempty"` + Style string `json:"style,omitempty"` + Provider Provider `json:"provider,omitempty"` + + // 新增主题字段 + Theme string `json:"theme,omitempty"` + EnableTheme bool `json:"enable_theme,omitempty"` + ThemeStrength float64 `json:"theme_strength,omitempty"` + CustomPrefix string `json:"custom_prefix,omitempty"` + CustomSuffix string `json:"custom_suffix,omitempty"` + Language string `json:"language,omitempty"` +} +``` + +## 核心算法 + +### 提示词增强流程 + +```go +func (tm *ThemeManager) BuildEnhancedPrompt(req *ProcessRequest, template *ThemeTemplate) string { + var parts []string + + // 1. 自定义前缀或主题前缀 + if req.CustomPrefix != "" { + parts = append(parts, req.CustomPrefix) + } else if template.PrefixPrompt != "" { + prefix := tm.selectPromptByLanguage(template.PrefixPrompt, template.PrefixPromptCN, req.Language) + if prefix != "" { + parts = append(parts, prefix) + } + } + + // 2. 用户原始提示词 + if req.Prompt != "" { + parts = append(parts, req.Prompt) + } + + // 3. 主题风格修饰(根据强度选择) + if req.ThemeStrength > 0.5 && len(template.StyleModifiers) > 0 { + modifiers := tm.selectModifiers(template.StyleModifiers, req.ThemeStrength) + parts = append(parts, modifiers...) + } + + // 4. 自定义后缀或主题后缀 + if req.CustomSuffix != "" { + parts = append(parts, req.CustomSuffix) + } else if template.SuffixPrompt != "" { + suffix := tm.selectPromptByLanguage(template.SuffixPrompt, template.SuffixPromptCN, req.Language) + if suffix != "" { + parts = append(parts, suffix) + } + } + + // 5. 质量增强词(总是添加) + if len(template.QualityEnhancers) > 0 { + parts = append(parts, template.QualityEnhancers...) + } + + return strings.Join(tm.filterEmptyStrings(parts), ", ") +} +``` + +### 主题强度影响 + +- **0.0 - 0.5**: 不应用风格修饰词,仅使用前缀、后缀和质量增强词 +- **0.5 - 1.0**: 根据强度比例选择风格修饰词数量 + +```go +func (tm *ThemeManager) selectModifiers(modifiers []string, strength float64) []string { + count := int(float64(len(modifiers)) * strength) + if count == 0 { + count = 1 + } + if count > len(modifiers) { + count = len(modifiers) + } + return modifiers[:count] +} +``` + +## 内置主题详情 + +### 1. 教育数学主题 (education_math) +- **用途**: 数学教育插图 +- **前缀**: "Educational mathematics illustration" / "教育数学插图" +- **后缀**: "clean vector design, educational style, suitable for children" / "简洁矢量设计,教育风格,适合儿童" +- **风格修饰**: geometric, colorful, friendly, simple +- **质量增强**: high quality, clean lines, professional + +### 2. 游戏卡通主题 (game_cartoon) +- **用途**: 游戏角色和卡通插图 +- **前缀**: "Cute cartoon game character" / "可爱卡通游戏角色" +- **后缀**: "vibrant colors, friendly appearance, game-ready design" / "鲜艳色彩,友好外观,游戏就绪设计" +- **风格修饰**: playful, colorful, expressive, rounded +- **质量增强**: detailed, polished, game art style + +### 3. UI图标主题 (ui_icon) +- **用途**: 用户界面图标 +- **前缀**: "Minimal UI icon" / "简约UI图标" +- **后缀**: "flat design, consistent style, scalable vector" / "扁平设计,一致风格,可缩放矢量" +- **风格修饰**: minimal, modern, clean, geometric +- **质量增强**: pixel perfect, scalable, optimized + +## API端点 + +### 主题查询端点 + +#### 获取所有主题 +``` +GET /image/themes +``` + +响应格式: +```json +{ + "themes": [ + { + "type": "education_math", + "name": "Education Math", + "name_cn": "教育数学", + "description": "Mathematical illustrations optimized for educational content", + "description_cn": "专为教育内容优化的数学插图", + "default_style": "educational_illustration", + "style_modifiers": ["geometric", "colorful", "friendly", "simple"], + "quality_enhancers": ["high quality", "clean lines", "professional"] + } + ], + "count": 8 +} +``` + +#### 获取特定主题 +``` +GET /image/themes/{theme_type} +``` + +### SVG生成端点(已扩展) + +``` +POST /image/svg +``` + +支持的主题参数: +```json +{ + "prompt": "一只小猫", + "theme": "game_cartoon", + "enable_theme": true, + "theme_strength": 0.8, + "custom_prefix": "可爱的", + "custom_suffix": "适合儿童", + "language": "zh", + "provider": "svgio" +} +``` + +## 配置管理 + +### 环境变量 + +```bash +# 主题功能开关 +SVG_THEMES_ENABLED=true + +# 主题配置文件路径(可选) +SVG_THEMES_CONFIG_PATH=./config/theme_templates.yaml + +# 默认主题 +SVG_DEFAULT_THEME=general + +# 主题缓存时间(秒) +SVG_THEMES_CACHE_TTL=3600 +``` + +### 配置文件支持 + +支持YAML格式的自定义主题配置文件: + +```yaml +themes: + custom_education: + name: "Custom Education" + name_cn: "自定义教育" + enabled: true + prefix_prompt: "Educational custom illustration" + prefix_prompt_cn: "教育自定义插图" + suffix_prompt: "child-friendly, educational, clear" + suffix_prompt_cn: "儿童友好,教育性,清晰" + default_style: "educational_custom" + style_modifiers: ["educational", "child-friendly", "colorful"] + quality_enhancers: ["high quality", "professional"] +``` + +## 使用示例 + +### 基础主题使用 + +```bash +curl -X POST http://localhost:8080/image/svg \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "一只小猫学数学", + "theme": "education_math", + "enable_theme": true, + "provider": "svgio" + }' +``` + +### 高级配置 + +```bash +curl -X POST http://localhost:8080/image/svg \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "molecular structure", + "theme": "education_science", + "enable_theme": true, + "theme_strength": 0.7, + "custom_suffix": "suitable for university textbook", + "language": "en", + "provider": "recraft" + }' +``` + +## 提示词变换示例 + +### 输入 +``` +原始提示词: "a cute cat" +主题: game_cartoon +强度: 0.8 +语言: en +``` + +### 处理流程 +1. **前缀**: "Cute cartoon game character" +2. **用户输入**: "a cute cat" +3. **风格修饰**: "playful, colorful, expressive, rounded" (4个中的4个,因为强度0.8) +4. **后缀**: "vibrant colors, friendly appearance, game-ready design" +5. **质量增强**: "detailed, polished, game art style" + +### 最终结果 +``` +"Cute cartoon game character, a cute cat, playful, colorful, expressive, rounded, vibrant colors, friendly appearance, game-ready design, detailed, polished, game art style" +``` + +## 性能特性 + +### 缓存策略 +- **内存缓存**: 所有主题模板在启动时加载到内存 +- **线程安全**: 使用读写锁保证并发安全 +- **热更新**: 支持运行时重载而无需重启服务 + +### 性能优化 +- **快速路径**: 未启用主题时零开销 +- **字符串优化**: 避免不必要的字符串分配和复制 +- **去重处理**: 自动过滤空字符串和重复内容 + +### 错误处理 +- **优雅降级**: 主题处理失败时自动回退到原始提示词 +- **详细日志**: 记录主题处理过程和错误信息 +- **不中断生成**: 主题错误不影响SVG生成的继续执行 + +## 监控和日志 + +### 关键日志 +```go +logger.Printf("Theme processing - theme: %s, original_prompt: %q, enhanced_prompt: %q", + req.Theme, originalPrompt, enhancedPrompt) +``` + +### 统计信息 +- 主题使用频率统计 +- 不同主题的成功率跟踪 +- 提示词长度分布分析 +- 生成时间对比数据 + +## 扩展性 + +### 自定义主题注册 +```go +customTheme := &ThemeTemplate{ + Type: ThemeType("custom_theme"), + Name: "Custom Theme", + PrefixPrompt: "Custom prefix", +} +manager.RegisterCustomTheme(customTheme) +``` + +### 未来扩展方向 +- **A/B测试**: 主题变体对比测试 +- **用户偏好**: 个性化主题推荐 +- **智能学习**: 基于用户反馈的主题优化 +- **更多主题**: 持续扩展内置主题库 + +## 测试策略 + +### 单元测试覆盖 +- 主题模板加载和管理 +- 提示词拼接逻辑验证 +- 多语言选择机制 +- 参数验证和错误处理 +- 并发安全性测试 + +### 集成测试 +- 端到端API功能测试 +- 多提供商兼容性验证 +- 性能基准测试 +- 内存使用和泄漏检测 + +## 部署指南 + +### 渐进式启用 +1. **第一阶段**: 部署代码,主题功能默认关闭 +2. **第二阶段**: 开启主题功能,监控系统表现 +3. **第三阶段**: 扩展主题库,收集用户反馈 +4. **第四阶段**: 优化算法,增强用户体验 + +### 兼容性保证 +- 现有API完全向下兼容 +- 新增字段均为可选参数 +- 提供完整的降级机制 +- 支持平滑的功能开关 + +这套主题系统为SVG生成服务提供了强大而灵活的主题化能力,在提升生成质量的同时保持了良好的用户体验和系统稳定性。 \ No newline at end of file From 896567a0a1b685fae1db4e8e3ad63742d2688098 Mon Sep 17 00:00:00 2001 From: petezhuang Date: Tue, 26 Aug 2025 09:53:35 +0800 Subject: [PATCH 45/49] refactor(spx-gui):Change AIGC interaction API --- spx-gui/src/apis/picgc.ts | 61 ++++++++++++++++--- .../painter/components/aigc/generator.vue | 12 ++-- .../editor/common/painter/painter.vue | 2 +- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/spx-gui/src/apis/picgc.ts b/spx-gui/src/apis/picgc.ts index 1965ee072..0642b83c5 100644 --- a/spx-gui/src/apis/picgc.ts +++ b/spx-gui/src/apis/picgc.ts @@ -2,12 +2,11 @@ * @desc Picture Generation APIs for AI image generation */ -// independent baseUrl -const PICGC_BASE_URL = 'http://localhost:8080' +import { apiBaseUrl } from '@/utils/env' // 简单的HTTP请求函数 async function picgcRequest(path: string, options: RequestInit = {}) { - const url = PICGC_BASE_URL + path + const url = apiBaseUrl + path const response = await fetch(url, { headers: { @@ -32,8 +31,24 @@ export type ImageModel = 'png' | 'svg' export interface GenerateImageRequest { /** The text prompt describing the desired image */ prompt: string + /** Negative prompt for things to avoid */ negative_prompt?: string + /** Image style (e.g., cartoon, realistic, etc.) */ + style?: string + /** AI provider (e.g., svgio, claude, recraft) */ + provider?: string + /** Output format (svg, png, etc.) */ format?: string + /** Whether to skip translation */ + skip_translate?: boolean + /** AI model to use (e.g., gpt-4) */ + model?: string + /** Image size (e.g., "512x512") */ + size?: string + /** Sub-style specification (e.g., hand-drawn) */ + substyle?: string + /** Number of images to generate */ + n?: number } /** Response from backend API */ @@ -54,13 +69,27 @@ export async function generateImage( prompt: string, options?: { negative_prompt?: string + style?: string + provider?: string format?: string + skip_translate?: boolean + model?: string + size?: string + substyle?: string + n?: number } ): Promise { const payload: GenerateImageRequest = { prompt, negative_prompt: options?.negative_prompt || 'text, watermark', - format: options?.format + style: options?.style, + provider: options?.provider, + format: options?.format, + skip_translate: options?.skip_translate, + model: options?.model, + size: options?.size, + substyle: options?.substyle, + n: options?.n } //生成图片的接口 @@ -80,7 +109,13 @@ export async function generateSvgDirect( prompt: string, options?: { negative_prompt?: string + style?: string format?: string + skip_translate?: boolean + model?: string + size?: string + substyle?: string + n?: number } ): Promise<{ svgContent: string @@ -91,16 +126,28 @@ export async function generateSvgDirect( const payload: GenerateImageRequest = { prompt, negative_prompt: options?.negative_prompt || 'text, watermark', - format: options?.format + style: options?.style, + provider: provider, + format: options?.format, + skip_translate: options?.skip_translate, + model: options?.model, + size: options?.size, + substyle: options?.substyle, + n: options?.n } let url = '' + console.log('provider', provider) + url = apiBaseUrl + '/image/svg' switch (provider) { case 'claude': - url = PICGC_BASE_URL + '/v1/images/claude/svg' + payload.provider = 'claude' break case 'recraft': - url = PICGC_BASE_URL + '/v1/images/recraft/svg' + payload.provider = 'recraft' + break + case 'svgio': + payload.provider = 'svgio' break default: throw new Error('Invalid provider') diff --git a/spx-gui/src/components/editor/common/painter/components/aigc/generator.vue b/spx-gui/src/components/editor/common/painter/components/aigc/generator.vue index 544bb80f2..2a0367f2c 100644 --- a/spx-gui/src/components/editor/common/painter/components/aigc/generator.vue +++ b/spx-gui/src/components/editor/common/painter/components/aigc/generator.vue @@ -32,12 +32,12 @@
🖼️
-
{{ $t({ en: 'Claude Vector', zh: 'Claude矢量图' }) }}
+
{{ $t({ en: 'SVGIO Vector', zh: 'SVGIO矢量图' }) }}
{{ $t({ en: 'Generate simple, accurate vector images', zh: '生成简单,精确的矢量图' }) }}
@@ -168,7 +168,7 @@ import ErrorModal from './error.vue' }>() // 响应式数据 - const selectedModel = ref<'claude' | 'recraft'>('claude') + const selectedModel = ref<'claude' | 'recraft' | 'svgio'>('claude') const prompt = ref('') const previewUrl = ref('') const isGenerating = ref(false) @@ -209,7 +209,7 @@ import ErrorModal from './error.vue' prompt: prompt.value } - if (selectedModel.value === 'recraft' || selectedModel.value === 'claude') { + if (selectedModel.value === 'recraft' || selectedModel.value === 'claude' || selectedModel.value === 'svgio') { // SVG模式:传递原始SVG代码 confirmData.svgContent = svgRawContent.value confirmData.url = previewUrl.value // 用于预览的blob URL @@ -273,7 +273,7 @@ import ErrorModal from './error.vue' svgRawContent.value = '' try { - if (selectedModel.value === 'recraft' || selectedModel.value === 'claude') { + if (selectedModel.value === 'recraft' || selectedModel.value === 'claude' || selectedModel.value === 'svgio') { const svgResult = await generateSvgDirect(selectedModel.value, prompt.value) // 直接获得SVG内容 diff --git a/spx-gui/src/components/editor/common/painter/painter.vue b/spx-gui/src/components/editor/common/painter/painter.vue index 8074005d3..601ac40fe 100644 --- a/spx-gui/src/components/editor/common/painter/painter.vue +++ b/spx-gui/src/components/editor/common/painter/painter.vue @@ -377,7 +377,7 @@ const handleAiConfirm = (data: { svgContent?: string; }): void => { // console.log('AI生成确认:', data) - if(data.model === 'claude' || data.model === 'recraft'){ + if(data.model === 'claude' || data.model === 'recraft' || data.model === 'svgio'){ data.model = 'svg' }else{ data.model = 'png' From e27180f8f7f0fc1a1a313124dd3923d36aacc7a0 Mon Sep 17 00:00:00 2001 From: qiniu Date: Tue, 26 Aug 2025 10:20:55 +0800 Subject: [PATCH 46/49] doc: add document of clip --- spx-algorithm/docs/v1.md | 111 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 spx-algorithm/docs/v1.md diff --git a/spx-algorithm/docs/v1.md b/spx-algorithm/docs/v1.md new file mode 100644 index 000000000..8ab7c0d32 --- /dev/null +++ b/spx-algorithm/docs/v1.md @@ -0,0 +1,111 @@ +# OpenCLIP 功能说明文档 + +## 简介 + +本项目基于 [OpenCLIP](https://github.com/mlfoundations/open_clip) 实现了一个图像搜索服务,能够通过自然语言描述来搜索和匹配图像。OpenCLIP 是一个开源的 CLIP(Contrastive Language-Image Pre-training)模型实现,能够理解图像和文本之间的语义关系。 + +## OpenCLIP 核心功能 + +### 1. 多模态理解 +- **文本编码**: 将自然语言文本转换为向量表示 +- **图像编码**: 将图像转换为语义向量表示 +- **跨模态匹配**: 计算文本和图像之间的语义相似度 + +### 2. 支持的模型类型 +项目中使用的预训练模型: +- `ViT-B-32`: Vision Transformer Base 模型,32x32 patch size +- 预训练数据集: `laion2b_s34b_b79k`(LAION-2B 数据集) + +### 3. 图像格式支持 +- **常规格式**: PNG, JPG, JPEG, WebP, BMP, TIFF +- **SVG 支持**: 通过 CairoSVG 转换为位图后处理 +- **预处理**: 自动调整图像尺寸到 224x224 像素 + +## 项目中的应用 + +### 核心服务类: ImageSearchService + +位置: `project/app/services/image_search_service.py` + +#### 主要功能方法: + +1. **模型初始化** (`_load_model`) +```python +# 加载 CLIP 模型和预处理器 +model, _, preprocess = open_clip.create_model_and_transforms( + model_name, pretrained=pretrained +) +tokenizer = open_clip.get_tokenizer(model_name) +``` + +2. **图像处理** (`_process_image`) +- 支持多种图像格式 +- SVG 文件特殊处理(转换为 PNG) +- 统一预处理到标准尺寸 + +3. **语义搜索** (`search_images`) +- 文本查询编码 +- 批量图像编码 +- 相似度计算和排序 + + +## API 接口 + +### 1. 文件上传搜索 (`/api/search`) +- **方法**: POST +- **输入**: 文本查询 + 图像文件 +- **输出**: 按相似度排序的结果列表 + +### 2. URL 搜索 (`/api/search/url`) +- **方法**: POST +- **输入**: 文本查询 + 图像URL列表 +- **输出**: 按相似度排序的结果列表 + +### 响应格式 +```json +{ + "query": "查询文本", + "total_images": 10, + "results_count": 5, + "results": [ + { + "rank": 1, + "similarity": 0.8567, + "filename": "image1.jpg" + } + ] +} +``` + +## 技术特性 + +### 1. 性能优化 +- **模型复用**: 服务启动时加载一次,避免重复加载 +- **批量处理**: 支持多图像并行编码 +- **GPU 加速**: 自动检测并使用 CUDA(如果可用) + +### 2. 错误处理 +- 图像文件验证 +- 格式不支持的优雅降级 +- 详细的错误日志记录 + +### 3. 扩展性 +- 可配置的模型参数 +- 支持不同的预训练权重 +- 易于集成新的图像格式 + + +## 配置选项 + +### 环境变量 +- `CLIP_MODEL_NAME`: CLIP 模型名称(默认: ViT-B-32) +- `CLIP_PRETRAINED`: 预训练权重(默认: laion2b_s34b_b79k) + +### 支持的模型类型 +- `ViT-B-32`: 轻量级,速度快 +- `ViT-B-16`: 更高精度 +- `ViT-L-14`: 大型模型,最高精度 +- `RN50/RN101`: ResNet 架构 + + + From 744fd3decd5a9514b0c27150a0959c8d73f68844 Mon Sep 17 00:00:00 2001 From: qiniu Date: Tue, 26 Aug 2025 10:41:08 +0800 Subject: [PATCH 47/49] doc: add document of test theme classifiation --- spx-algorithm/docs/v2.md | 124 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 spx-algorithm/docs/v2.md diff --git a/spx-algorithm/docs/v2.md b/spx-algorithm/docs/v2.md new file mode 100644 index 000000000..ce7c29066 --- /dev/null +++ b/spx-algorithm/docs/v2.md @@ -0,0 +1,124 @@ +# OpenCLIP 功能说明文档 + +## 简介 + +本项目基于 [OpenCLIP](https://github.com/mlfoundations/open_clip) 实现了一个图像搜索服务,能够通过自然语言描述来搜索和匹配图像。OpenCLIP 是一个开源的 CLIP(Contrastive Language-Image Pre-training)模型实现,能够理解图像和文本之间的语义关系。 + +## OpenCLIP 核心功能 + +### 1. 多模态理解 +- **文本编码**: 将自然语言文本转换为向量表示 +- **图像编码**: 将图像转换为语义向量表示 +- **跨模态匹配**: 计算文本和图像之间的语义相似度 + +### 2. 支持的模型类型 +项目中使用的预训练模型: +- `ViT-B-32`: Vision Transformer Base 模型,32x32 patch size +- 预训练数据集: `laion2b_s34b_b79k`(LAION-2B 数据集) + +### 3. 图像格式支持 +- **常规格式**: PNG, JPG, JPEG, WebP, BMP, TIFF +- **SVG 支持**: 通过 CairoSVG 转换为位图后处理 +- **预处理**: 自动调整图像尺寸到 224x224 像素 + +## 项目中的应用 + +### 核心服务类: ImageSearchService + +位置: `project/app/services/image_search_service.py` + +#### 主要功能方法: + +1. **模型初始化** (`_load_model`) +```python +# 加载 CLIP 模型和预处理器 +model, _, preprocess = open_clip.create_model_and_transforms( + model_name, pretrained=pretrained +) +tokenizer = open_clip.get_tokenizer(model_name) +``` + +2. **图像处理** (`_process_image`) +- 支持多种图像格式 +- SVG 文件特殊处理(转换为 PNG) +- 统一预处理到标准尺寸 + +3. **语义搜索** (`search_images`) +- 文本查询编码 +- 批量图像编码 +- 相似度计算和排序 + + +## API 接口 + +### 1. 文件上传搜索 (`/api/search`) +- **方法**: POST +- **输入**: 文本查询 + 图像文件 +- **输出**: 按相似度排序的结果列表 + +### 2. URL 搜索 (`/api/search/url`) +- **方法**: POST +- **输入**: 文本查询 + 图像URL列表 +- **输出**: 按相似度排序的结果列表 + +### 响应格式 +```json +{ + "query": "查询文本", + "total_images": 10, + "results_count": 5, + "results": [ + { + "rank": 1, + "similarity": 0.8567, + "filename": "image1.jpg" + } + ] +} +``` + +## 技术特性 + +### 1. 性能优化 +- **模型复用**: 服务启动时加载一次,避免重复加载 +- **批量处理**: 支持多图像并行编码 +- **GPU 加速**: 自动检测并使用 CUDA(如果可用) + +### 2. 错误处理 +- 图像文件验证 +- 格式不支持的优雅降级 +- 详细的错误日志记录 + +### 3. 扩展性 +- 可配置的模型参数 +- 支持不同的预训练权重 +- 易于集成新的图像格式 + + +## 配置选项 + +### 环境变量 +- `CLIP_MODEL_NAME`: CLIP 模型名称(默认: ViT-B-32) +- `CLIP_PRETRAINED`: 预训练权重(默认: laion2b_s34b_b79k) + +### 支持的模型类型 +- `ViT-B-32`: 轻量级,速度快 +- `ViT-B-16`: 更高精度 +- `ViT-L-14`: 大型模型,最高精度 +- `RN50/RN101`: ResNet 架构 + + + +## 效果演示 + +### **模拟数据库样本** + +![img](https://biffmzeipku.feishu.cn/space/api/box/stream/download/asynccode/?code=NmM3YjUxZGE4MzBkOGFjNDY0OWYzNjc2NDFjM2EwMDRfR3pPcHI3MlhhSkxUdTVxRjF3MGduYUVaOW9BbWFjUjBfVG9rZW46Q0toRGJ5VjQwb0ptZzl4UHZhVWNscEsybjRkXzE3NTYxNzU3OTQ6MTc1NjE3OTM5NF9WNA) + +### **运行结果** + +![img](https://biffmzeipku.feishu.cn/space/api/box/stream/download/asynccode/?code=YWU5M2M2NWRmZjhjNWJiYTlhZWYwOTU1NmMzNmE0YTNfaWo0d3dEa0JyeVN2dk0zRnlEOFl4aGRha0NTWGhMOXNfVG9rZW46UHFPS2JvaUR2b2Q0Uld4RVd3a2M1emNyblVmXzE3NTYxNzU3OTQ6MTc1NjE3OTM5NF9WNA) + +![img](https://biffmzeipku.feishu.cn/space/api/box/stream/download/asynccode/?code=YzQ5MjYyYTM5ZDc3ODA2MjIxNGIxYTk4NmRiY2I2YjJfQkhlZjFobTlwRzJ2RWtiaXpPdm4zWk9FTWVMVzlHYXlfVG9rZW46UWJ5b2I0V01Yb3k1N2x4RlJaRWNtRmt6bkxoXzE3NTYxNzU3OTQ6MTc1NjE3OTM5NF9WNA) + +![img](https://biffmzeipku.feishu.cn/space/api/box/stream/download/asynccode/?code=MzJhYjE5MzMzYjI3ZDBjNGJlMWZkOWVmMmVhNDc4ZDlfY2pmdDlPQXVlR1RoTXJ3VjhCblV5UVlVblJrMm1uTjFfVG9rZW46Um9YYmJpeTIxb2VMUjZ4S1IyMWNyakR0blBoXzE3NTYxNzU3OTQ6MTc1NjE3OTM5NF9WNA) \ No newline at end of file From 454f13f3f0096ba56d45213d37cced141dcd577f Mon Sep 17 00:00:00 2001 From: linjc Date: Tue, 26 Aug 2025 11:31:37 +0800 Subject: [PATCH 48/49] feature:add theme prompt --- spx-backend/cmd/spx-backend/get_themes.yap | 18 ++ spx-backend/docs/THEME_FEATURE.md | 222 ++++++++++++++++++ spx-backend/internal/controller/svg.go | 39 ++- spx-backend/internal/controller/theme.go | 132 +++++++++++ spx-backend/internal/controller/theme_test.go | 195 +++++++++++++++ spx-backend/internal/svggen/types.go | 1 + 6 files changed, 603 insertions(+), 4 deletions(-) create mode 100644 spx-backend/cmd/spx-backend/get_themes.yap create mode 100644 spx-backend/docs/THEME_FEATURE.md create mode 100644 spx-backend/internal/controller/theme.go create mode 100644 spx-backend/internal/controller/theme_test.go diff --git a/spx-backend/cmd/spx-backend/get_themes.yap b/spx-backend/cmd/spx-backend/get_themes.yap new file mode 100644 index 000000000..383729688 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_themes.yap @@ -0,0 +1,18 @@ +// Get all available themes for image generation. +// +// Request: +// GET /themes + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +result, err := ctrl.GetThemes(ctx.Context()) +if err != nil { + replyWithInnerError(ctx, err) + return +} + +json result \ No newline at end of file diff --git a/spx-backend/docs/THEME_FEATURE.md b/spx-backend/docs/THEME_FEATURE.md new file mode 100644 index 000000000..351b44c0b --- /dev/null +++ b/spx-backend/docs/THEME_FEATURE.md @@ -0,0 +1,222 @@ +# SVG生成主题功能文档 + +## 概述 + +SVG生成主题功能为图片生成提供了预定义的风格主题,通过在提示词中自动添加主题相关的风格描述,确保生成的图片严格遵循指定的视觉风格。 + +## 功能特性 + +- **9种预定义主题**:涵盖卡通、写实、极简等多种风格 +- **严格风格控制**:使用强制性指令词汇确保AI严格遵循风格要求 +- **自动提示词增强**:后端自动将主题提示词拼接到用户原始提示词 +- **主题查询API**:前端可动态获取所有可用主题信息 +- **中文支持**:提供中文名称、描述和提示词 + +## 支持的主题 + +### 1. 无主题 (ThemeNone) +- **ID**: `""` +- **中文名**: 无主题 +- **描述**: 不应用任何特定主题风格 +- **提示词**: 无 + +### 2. 卡通风格 (ThemeCartoon) +- **ID**: `"cartoon"` +- **中文名**: 卡通风格 +- **描述**: 色彩鲜艳的卡通风格,适合可爱有趣的内容 +- **提示词**: 必须使用卡通风格,必须色彩鲜艳丰富,必须可爱有趣,严格使用简单几何形状,强制使用明亮饱和的色彩,禁止写实细节 + +### 3. 写实风格 (ThemeRealistic) +- **ID**: `"realistic"` +- **中文名**: 写实风格 +- **描述**: 高度写实的风格,细节丰富逼真 +- **提示词**: 必须使用写实风格,严格要求高度细节化,强制逼真效果,必须专业高质量渲染,禁止卡通化或简化元素 + +### 4. 极简风格 (ThemeMinimal) +- **ID**: `"minimal"` +- **中文名**: 极简风格 +- **描述**: 极简主义风格,简洁干净的设计 +- **提示词**: 必须使用极简风格,严格限制元素数量,强制使用干净线条和几何形状,严格使用黑白或单色调,禁止复杂装饰 + +### 5. 奇幻风格 (ThemeFantasy) +- **ID**: `"fantasy"` +- **中文名**: 奇幻风格 +- **描述**: 充满魔法和超自然元素的奇幻风格 +- **提示词**: 必须使用奇幻魔法风格,强制添加神秘魔法元素,严格使用梦幻色彩,必须包含超自然效果,禁止现实主义元素 + +### 6. 复古风格 (ThemeRetro) +- **ID**: `"retro"` +- **中文名**: 复古风格 +- **描述**: 怀旧复古风格,经典老式美学 +- **提示词**: 必须使用复古怀旧风格,严格遵循经典老式美学,强制使用怀旧色调和设计元素,禁止现代化元素 + +### 7. 科幻风格 (ThemeScifi) +- **ID**: `"scifi"` +- **中文名**: 科幻风格 +- **描述**: 未来科技风格,充满科幻元素 +- **提示词**: 必须使用科幻未来风格,强制添加科技元素,严格使用霓虹和金属色彩,必须包含未来感设计,禁止传统元素 + +### 8. 自然风格 (ThemeNature) +- **ID**: `"nature"` +- **中文名**: 自然风格 +- **描述**: 自然有机风格,使用自然元素和大地色调 +- **提示词**: 必须使用自然有机风格,严格使用自然元素和植物,强制使用大地色调和绿色系,禁止人工几何元素 + +### 9. 商务风格 (ThemeBusiness) +- **ID**: `"business"` +- **中文名**: 商务风格 +- **描述**: 专业商务风格,现代企业形象 +- **提示词**: 必须使用商务专业风格,严格保持企业形象,强制使用现代简洁设计,必须专业精致,禁止卡通或娱乐元素 + +## API接口 + +### 1. 查询所有主题 + +**接口地址**: `GET /themes` + +**响应格式**: +```json +[ + { + "id": "cartoon", + "name": "卡通风格", + "description": "色彩鲜艳的卡通风格,适合可爱有趣的内容", + "prompt": "必须使用卡通风格,必须色彩鲜艳丰富,必须可爱有趣,严格使用简单几何形状,强制使用明亮饱和的色彩,禁止写实细节" + } + // ... 其他主题 +] +``` + +### 2. 生成SVG(支持主题) + +**接口地址**: `POST /image/svg` + +**请求参数**: +```json +{ + "prompt": "一只可爱的小猫", + "theme": "cartoon", + "provider": "svgio" +} +``` + +**处理流程**: +1. 验证主题参数是否有效 +2. 应用主题提示词:`"一只可爱的小猫,必须使用卡通风格,必须色彩鲜艳丰富..."` +3. 调用图片生成服务 +4. 返回生成的SVG内容 + +### 3. 生成图片元数据(支持主题) + +**接口地址**: `POST /image` + +**请求参数**: 与生成SVG相同 + +**响应**: 返回图片元数据信息,包含URL、尺寸等 + +## 实现细节 + +### 核心文件 + +1. **`internal/controller/theme.go`** + - 定义主题类型和常量 + - 提供主题信息映射 + - 实现提示词拼接逻辑 + - 提供主题查询方法 + +2. **`internal/controller/svg.go`** + - 集成主题功能到生成接口 + - 添加主题查询controller方法 + +3. **`cmd/spx-backend/get_themes.yap`** + - 主题查询API端点 + +### 核心方法 + +#### 1. 主题验证 +```go +func IsValidTheme(theme ThemeType) bool +``` + +#### 2. 提示词拼接 +```go +func ApplyThemeToPrompt(originalPrompt string, theme ThemeType) string +``` + +#### 3. 主题信息查询 +```go +func GetAllThemesInfo() []ThemeInfo +func GetThemeInfo(theme ThemeType) ThemeInfo +``` + +### 提示词设计原则 + +1. **强制性指令**: 使用"必须"、"严格"、"强制"等命令性词汇 +2. **明确要求**: 详细描述每个主题的具体视觉特征 +3. **禁止条款**: 明确禁止与主题不符的元素 +4. **中文优化**: 使用中文逗号拼接,符合中文表达习惯 + +## 使用示例 + +### 前端获取主题列表 +```javascript +fetch('/themes') + .then(response => response.json()) + .then(themes => { + themes.forEach(theme => { + console.log(`${theme.name}: ${theme.description}`); + }); + }); +``` + +### 前端生成图片 +```javascript +const request = { + prompt: "一座美丽的城堡", + theme: "fantasy", + provider: "svgio" +}; + +fetch('/image/svg', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(request) +}); +``` + +### 实际处理效果 +- **原始提示词**: "一座美丽的城堡" +- **应用奇幻主题后**: "一座美丽的城堡,必须使用奇幻魔法风格,强制添加神秘魔法元素,严格使用梦幻色彩,必须包含超自然效果,禁止现实主义元素" + +## 测试覆盖 + +项目包含完整的单元测试,覆盖以下功能: + +1. 主题验证逻辑 +2. 提示词拼接功能 +3. 主题信息查询 +4. 参数验证 + +测试文件: `internal/controller/theme_test.go` + +## 扩展性 + +### 添加新主题 + +1. 在 `ThemeType` 中定义新常量 +2. 在 `ThemePrompts` 中添加提示词 +3. 在 `ThemeNames` 中添加中文名称 +4. 在 `ThemeDescriptions` 中添加描述 +5. 更新 `GetAvailableThemes()` 方法 +6. 添加对应的测试用例 + +### 主题定制 + +可以通过修改 `ThemePrompts` 中的提示词来调整主题效果,或者添加更细粒度的主题参数控制。 + +## 注意事项 + +1. 主题功能会修改用户的原始提示词,在日志中会记录修改前后的内容 +2. 无主题选项 (`ThemeNone`) 不会修改原始提示词 +3. 主题验证在参数验证阶段进行,无效主题会返回错误 +4. 所有主题信息通过API动态获取,支持后端热更新 \ No newline at end of file diff --git a/spx-backend/internal/controller/svg.go b/spx-backend/internal/controller/svg.go index 655d4125c..68af39edf 100644 --- a/spx-backend/internal/controller/svg.go +++ b/spx-backend/internal/controller/svg.go @@ -18,6 +18,7 @@ type GenerateSVGParams struct { Prompt string `json:"prompt"` NegativePrompt string `json:"negative_prompt,omitempty"` Style string `json:"style,omitempty"` + Theme ThemeType `json:"theme,omitempty"` Provider svggen.Provider `json:"provider,omitempty"` Format string `json:"format,omitempty"` SkipTranslate bool `json:"skip_translate,omitempty"` @@ -54,6 +55,11 @@ func (p *GenerateSVGParams) Validate() (bool, string) { return false, "provider must be one of: svgio, recraft, openai" } + // Validate theme + if !IsValidTheme(p.Theme) { + return false, "invalid theme type" + } + return true, "" } @@ -85,13 +91,20 @@ type ImageResponse struct { // GenerateSVG generates an SVG image and returns the SVG content directly. func (ctrl *Controller) GenerateSVG(ctx context.Context, params *GenerateSVGParams) (*SVGResponse, error) { logger := log.GetReqLogger(ctx) - logger.Printf("GenerateSVG request - provider: %s, prompt: %q", params.Provider, params.Prompt) + logger.Printf("GenerateSVG request - provider: %s, theme: %s, prompt: %q", params.Provider, params.Theme, params.Prompt) + + // Apply theme to prompt if specified + finalPrompt := ApplyThemeToPrompt(params.Prompt, params.Theme) + if params.Theme != ThemeNone { + logger.Printf("Theme applied - original: %q, enhanced: %q", params.Prompt, finalPrompt) + } // Convert to svggen request req := svggen.GenerateRequest{ - Prompt: params.Prompt, + Prompt: finalPrompt, NegativePrompt: params.NegativePrompt, Style: params.Style, + Theme: string(params.Theme), Provider: params.Provider, Format: params.Format, SkipTranslate: params.SkipTranslate, @@ -143,13 +156,20 @@ func (ctrl *Controller) GenerateSVG(ctx context.Context, params *GenerateSVGPara // GenerateImage generates an image and returns metadata information. func (ctrl *Controller) GenerateImage(ctx context.Context, params *GenerateImageParams) (*ImageResponse, error) { logger := log.GetReqLogger(ctx) - logger.Printf("GenerateImage request - provider: %s, prompt: %q", params.Provider, params.Prompt) + logger.Printf("GenerateImage request - provider: %s, theme: %s, prompt: %q", params.Provider, params.Theme, params.Prompt) + + // Apply theme to prompt if specified + finalPrompt := ApplyThemeToPrompt(params.Prompt, params.Theme) + if params.Theme != ThemeNone { + logger.Printf("Theme applied - original: %q, enhanced: %q", params.Prompt, finalPrompt) + } // Convert to svggen request req := svggen.GenerateRequest{ - Prompt: params.Prompt, + Prompt: finalPrompt, NegativePrompt: params.NegativePrompt, Style: params.Style, + Theme: string(params.Theme), Provider: params.Provider, Format: params.Format, SkipTranslate: params.SkipTranslate, @@ -227,4 +247,15 @@ func (ctrl *Controller) parseDataURL(dataURL string) ([]byte, error) { // Not base64 encoded, return string bytes directly return []byte(data), nil +} + +// GetThemes returns all available themes with their information. +func (ctrl *Controller) GetThemes(ctx context.Context) ([]ThemeInfo, error) { + logger := log.GetReqLogger(ctx) + logger.Printf("GetThemes request") + + themes := GetAllThemesInfo() + + logger.Printf("Returned %d themes", len(themes)) + return themes, nil } \ No newline at end of file diff --git a/spx-backend/internal/controller/theme.go b/spx-backend/internal/controller/theme.go new file mode 100644 index 000000000..c926019e4 --- /dev/null +++ b/spx-backend/internal/controller/theme.go @@ -0,0 +1,132 @@ +package controller + +import "fmt" + +// ThemeType represents different SVG generation themes +type ThemeType string + +const ( + ThemeNone ThemeType = "" + ThemeCartoon ThemeType = "cartoon" + ThemeRealistic ThemeType = "realistic" + ThemeMinimal ThemeType = "minimal" + ThemeFantasy ThemeType = "fantasy" + ThemeRetro ThemeType = "retro" + ThemeScifi ThemeType = "scifi" + ThemeNature ThemeType = "nature" + ThemeBusiness ThemeType = "business" +) + +// ThemeInfo represents detailed information about a theme +type ThemeInfo struct { + ID ThemeType `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Prompt string `json:"prompt"` +} + +// ThemePrompts maps each theme to its corresponding prompt enhancement +var ThemePrompts = map[ThemeType]string{ + ThemeCartoon: "必须使用卡通风格,必须色彩鲜艳丰富,必须可爱有趣,严格使用简单几何形状,强制使用明亮饱和的色彩,禁止写实细节", + ThemeRealistic: "必须使用写实风格,严格要求高度细节化,强制逼真效果,必须专业高质量渲染,禁止卡通化或简化元素", + ThemeMinimal: "必须使用极简风格,严格限制元素数量,强制使用干净线条和几何形状,严格使用黑白或单色调,禁止复杂装饰", + ThemeFantasy: "必须使用奇幻魔法风格,强制添加神秘魔法元素,严格使用梦幻色彩,必须包含超自然效果,禁止现实主义元素", + ThemeRetro: "必须使用复古怀旧风格,严格遵循经典老式美学,强制使用怀旧色调和设计元素,禁止现代化元素", + ThemeScifi: "必须使用科幻未来风格,强制添加科技元素,严格使用霓虹和金属色彩,必须包含未来感设计,禁止传统元素", + ThemeNature: "必须使用自然有机风格,严格使用自然元素和植物,强制使用大地色调和绿色系,禁止人工几何元素", + ThemeBusiness: "必须使用商务专业风格,严格保持企业形象,强制使用现代简洁设计,必须专业精致,禁止卡通或娱乐元素", +} + +// ThemeNames maps each theme to its Chinese name +var ThemeNames = map[ThemeType]string{ + ThemeNone: "无主题", + ThemeCartoon: "卡通风格", + ThemeRealistic: "写实风格", + ThemeMinimal: "极简风格", + ThemeFantasy: "奇幻风格", + ThemeRetro: "复古风格", + ThemeScifi: "科幻风格", + ThemeNature: "自然风格", + ThemeBusiness: "商务风格", +} + +// ThemeDescriptions maps each theme to its description +var ThemeDescriptions = map[ThemeType]string{ + ThemeNone: "不应用任何特定主题风格", + ThemeCartoon: "色彩鲜艳的卡通风格,适合可爱有趣的内容", + ThemeRealistic: "高度写实的风格,细节丰富逼真", + ThemeMinimal: "极简主义风格,简洁干净的设计", + ThemeFantasy: "充满魔法和超自然元素的奇幻风格", + ThemeRetro: "怀旧复古风格,经典老式美学", + ThemeScifi: "未来科技风格,充满科幻元素", + ThemeNature: "自然有机风格,使用自然元素和大地色调", + ThemeBusiness: "专业商务风格,现代企业形象", +} + +// IsValidTheme checks if the given theme is valid +func IsValidTheme(theme ThemeType) bool { + if theme == ThemeNone { + return true + } + _, exists := ThemePrompts[theme] + return exists +} + +// GetThemePromptEnhancement returns the prompt enhancement for a given theme +func GetThemePromptEnhancement(theme ThemeType) string { + if theme == ThemeNone { + return "" + } + return ThemePrompts[theme] +} + +// ApplyThemeToPrompt applies theme enhancement to the original prompt +func ApplyThemeToPrompt(originalPrompt string, theme ThemeType) string { + if theme == ThemeNone { + return originalPrompt + } + + themeEnhancement := GetThemePromptEnhancement(theme) + if themeEnhancement == "" { + return originalPrompt + } + + return fmt.Sprintf("%s,%s", originalPrompt, themeEnhancement) +} + +// GetAvailableThemes returns all available themes +func GetAvailableThemes() []ThemeType { + return []ThemeType{ + ThemeNone, + ThemeCartoon, + ThemeRealistic, + ThemeMinimal, + ThemeFantasy, + ThemeRetro, + ThemeScifi, + ThemeNature, + ThemeBusiness, + } +} + +// GetThemeInfo returns detailed information for a specific theme +func GetThemeInfo(theme ThemeType) ThemeInfo { + return ThemeInfo{ + ID: theme, + Name: ThemeNames[theme], + Description: ThemeDescriptions[theme], + Prompt: ThemePrompts[theme], + } +} + +// GetAllThemesInfo returns detailed information for all themes +func GetAllThemesInfo() []ThemeInfo { + themes := GetAvailableThemes() + result := make([]ThemeInfo, len(themes)) + + for i, theme := range themes { + result[i] = GetThemeInfo(theme) + } + + return result +} \ No newline at end of file diff --git a/spx-backend/internal/controller/theme_test.go b/spx-backend/internal/controller/theme_test.go new file mode 100644 index 000000000..5c48bc6a4 --- /dev/null +++ b/spx-backend/internal/controller/theme_test.go @@ -0,0 +1,195 @@ +package controller + +import ( + "testing" +) + +func TestIsValidTheme(t *testing.T) { + tests := []struct { + theme ThemeType + expected bool + }{ + {ThemeNone, true}, + {ThemeCartoon, true}, + {ThemeRealistic, true}, + {ThemeMinimal, true}, + {ThemeFantasy, true}, + {ThemeRetro, true}, + {ThemeScifi, true}, + {ThemeNature, true}, + {ThemeBusiness, true}, + {"invalid", false}, + {"", true}, // ThemeNone + } + + for _, test := range tests { + result := IsValidTheme(test.theme) + if result != test.expected { + t.Errorf("IsValidTheme(%q) = %v, expected %v", test.theme, result, test.expected) + } + } +} + +func TestGetThemePromptEnhancement(t *testing.T) { + tests := []struct { + theme ThemeType + expected string + }{ + {ThemeNone, ""}, + {ThemeCartoon, "必须使用卡通风格,必须色彩鲜艳丰富,必须可爱有趣,严格使用简单几何形状,强制使用明亮饱和的色彩,禁止写实细节"}, + {ThemeRealistic, "必须使用写实风格,严格要求高度细节化,强制逼真效果,必须专业高质量渲染,禁止卡通化或简化元素"}, + {ThemeMinimal, "必须使用极简风格,严格限制元素数量,强制使用干净线条和几何形状,严格使用黑白或单色调,禁止复杂装饰"}, + {ThemeFantasy, "必须使用奇幻魔法风格,强制添加神秘魔法元素,严格使用梦幻色彩,必须包含超自然效果,禁止现实主义元素"}, + } + + for _, test := range tests { + result := GetThemePromptEnhancement(test.theme) + if result != test.expected { + t.Errorf("GetThemePromptEnhancement(%q) = %q, expected %q", test.theme, result, test.expected) + } + } +} + +func TestApplyThemeToPrompt(t *testing.T) { + tests := []struct { + prompt string + theme ThemeType + expected string + }{ + {"一只猫", ThemeNone, "一只猫"}, + {"一只猫", ThemeCartoon, "一只猫,必须使用卡通风格,必须色彩鲜艳丰富,必须可爱有趣,严格使用简单几何形状,强制使用明亮饱和的色彩,禁止写实细节"}, + {"一座房子", ThemeMinimal, "一座房子,必须使用极简风格,严格限制元素数量,强制使用干净线条和几何形状,严格使用黑白或单色调,禁止复杂装饰"}, + {"", ThemeCartoon, ",必须使用卡通风格,必须色彩鲜艳丰富,必须可爱有趣,严格使用简单几何形状,强制使用明亮饱和的色彩,禁止写实细节"}, + } + + for _, test := range tests { + result := ApplyThemeToPrompt(test.prompt, test.theme) + if result != test.expected { + t.Errorf("ApplyThemeToPrompt(%q, %q) = %q, expected %q", test.prompt, test.theme, result, test.expected) + } + } +} + +func TestGetAvailableThemes(t *testing.T) { + themes := GetAvailableThemes() + expectedCount := 9 // ThemeNone + 8 themed options + + if len(themes) != expectedCount { + t.Errorf("GetAvailableThemes() returned %d themes, expected %d", len(themes), expectedCount) + } + + // Check that all themes are valid + for _, theme := range themes { + if !IsValidTheme(theme) { + t.Errorf("GetAvailableThemes() returned invalid theme: %q", theme) + } + } +} + +func TestGenerateSVGParamsValidateWithTheme(t *testing.T) { + tests := []struct { + params GenerateSVGParams + valid bool + errorMsg string + }{ + { + params: GenerateSVGParams{ + Prompt: "test prompt", + Theme: ThemeCartoon, + }, + valid: true, + }, + { + params: GenerateSVGParams{ + Prompt: "test prompt", + Theme: ThemeNone, + }, + valid: true, + }, + { + params: GenerateSVGParams{ + Prompt: "test prompt", + Theme: "invalid_theme", + }, + valid: false, + errorMsg: "invalid theme type", + }, + { + params: GenerateSVGParams{ + Prompt: "ab", // too short + Theme: ThemeCartoon, + }, + valid: false, + errorMsg: "prompt must be at least 3 characters", + }, + } + + for i, test := range tests { + valid, msg := test.params.Validate() + if valid != test.valid { + t.Errorf("Test %d: Validate() = %v, expected %v", i, valid, test.valid) + } + if !test.valid && msg != test.errorMsg { + t.Errorf("Test %d: Validate() error message = %q, expected %q", i, msg, test.errorMsg) + } + } +} + +func TestGetThemeInfo(t *testing.T) { + info := GetThemeInfo(ThemeCartoon) + + expected := ThemeInfo{ + ID: ThemeCartoon, + Name: "卡通风格", + Description: "色彩鲜艳的卡通风格,适合可爱有趣的内容", + Prompt: "必须使用卡通风格,必须色彩鲜艳丰富,必须可爱有趣,严格使用简单几何形状,强制使用明亮饱和的色彩,禁止写实细节", + } + + if info.ID != expected.ID { + t.Errorf("GetThemeInfo ID = %v, expected %v", info.ID, expected.ID) + } + if info.Name != expected.Name { + t.Errorf("GetThemeInfo Name = %v, expected %v", info.Name, expected.Name) + } + if info.Description != expected.Description { + t.Errorf("GetThemeInfo Description = %v, expected %v", info.Description, expected.Description) + } + if info.Prompt != expected.Prompt { + t.Errorf("GetThemeInfo Prompt = %v, expected %v", info.Prompt, expected.Prompt) + } +} + +func TestGetAllThemesInfo(t *testing.T) { + themes := GetAllThemesInfo() + expectedCount := 9 + + if len(themes) != expectedCount { + t.Errorf("GetAllThemesInfo returned %d themes, expected %d", len(themes), expectedCount) + } + + // Check that all themes have required fields + for _, theme := range themes { + if theme.ID == "" && theme.Name != "无主题" { + t.Errorf("Theme ID is empty for theme: %s", theme.Name) + } + if theme.Name == "" { + t.Errorf("Theme Name is empty for ID: %s", theme.ID) + } + if theme.ID != ThemeNone && theme.Prompt == "" { + t.Errorf("Theme Prompt is empty for ID: %s", theme.ID) + } + // Description can be empty for some themes, so we don't check it + } + + // Check that ThemeNone is included + found := false + for _, theme := range themes { + if theme.ID == ThemeNone { + found = true + break + } + } + if !found { + t.Errorf("ThemeNone not found in GetAllThemesInfo result") + } +} \ No newline at end of file diff --git a/spx-backend/internal/svggen/types.go b/spx-backend/internal/svggen/types.go index 16469d5a0..ea4cf17fb 100644 --- a/spx-backend/internal/svggen/types.go +++ b/spx-backend/internal/svggen/types.go @@ -16,6 +16,7 @@ type GenerateRequest struct { Prompt string `json:"prompt"` NegativePrompt string `json:"negative_prompt,omitempty"` Style string `json:"style,omitempty"` + Theme string `json:"theme,omitempty"` Provider Provider `json:"provider,omitempty"` Format string `json:"format,omitempty"` SkipTranslate bool `json:"skip_translate,omitempty"` From aaa112e1d5aba7dea6dd7b53ab9ef2fedac0eb2e Mon Sep 17 00:00:00 2001 From: qiniu Date: Tue, 26 Aug 2025 14:09:53 +0800 Subject: [PATCH 49/49] feature: add svg database to current project --- .../resource/vector_db/README_vector_db.md | 190 ++++++++ .../vector_database.cpython-312.pyc | Bin 0 -> 18146 bytes .../vector_db_utils.cpython-312.pyc | Bin 0 -> 9205 bytes .../vector_db/batch_example_db/index.faiss | Bin 0 -> 6189 bytes .../vector_db/batch_example_db/metadata.pkl | Bin 0 -> 847 bytes spx-algorithm/resource/vector_db/cute.svg | 1 + spx-algorithm/resource/vector_db/cute2.svg | 1 + .../resource/vector_db/database_metadata.json | 42 ++ .../resource/vector_db/demo_vector_db.py | 117 +++++ .../vector_db/demo_vector_db/index.faiss | Bin 0 -> 6189 bytes .../vector_db/demo_vector_db/metadata.pkl | Bin 0 -> 847 bytes spx-algorithm/resource/vector_db/dog.svg | 213 +++++++++ .../resource/vector_db/example_db/index.faiss | Bin 0 -> 2093 bytes .../vector_db/example_db/metadata.pkl | Bin 0 -> 297 bytes .../vector_db/requirements_vector_db.txt | 8 + .../vector_db/simple_usage_example.py | 90 ++++ .../resource/vector_db/vector_database.py | 417 ++++++++++++++++++ .../resource/vector_db/vector_db_utils.py | 213 +++++++++ 18 files changed, 1292 insertions(+) create mode 100644 spx-algorithm/resource/vector_db/README_vector_db.md create mode 100644 spx-algorithm/resource/vector_db/__pycache__/vector_database.cpython-312.pyc create mode 100644 spx-algorithm/resource/vector_db/__pycache__/vector_db_utils.cpython-312.pyc create mode 100644 spx-algorithm/resource/vector_db/batch_example_db/index.faiss create mode 100644 spx-algorithm/resource/vector_db/batch_example_db/metadata.pkl create mode 100644 spx-algorithm/resource/vector_db/cute.svg create mode 100644 spx-algorithm/resource/vector_db/cute2.svg create mode 100644 spx-algorithm/resource/vector_db/database_metadata.json create mode 100644 spx-algorithm/resource/vector_db/demo_vector_db.py create mode 100644 spx-algorithm/resource/vector_db/demo_vector_db/index.faiss create mode 100644 spx-algorithm/resource/vector_db/demo_vector_db/metadata.pkl create mode 100644 spx-algorithm/resource/vector_db/dog.svg create mode 100644 spx-algorithm/resource/vector_db/example_db/index.faiss create mode 100644 spx-algorithm/resource/vector_db/example_db/metadata.pkl create mode 100644 spx-algorithm/resource/vector_db/requirements_vector_db.txt create mode 100644 spx-algorithm/resource/vector_db/simple_usage_example.py create mode 100644 spx-algorithm/resource/vector_db/vector_database.py create mode 100644 spx-algorithm/resource/vector_db/vector_db_utils.py diff --git a/spx-algorithm/resource/vector_db/README_vector_db.md b/spx-algorithm/resource/vector_db/README_vector_db.md new file mode 100644 index 000000000..2def3cc56 --- /dev/null +++ b/spx-algorithm/resource/vector_db/README_vector_db.md @@ -0,0 +1,190 @@ +# 图像向量数据库 + +基于open-clip的图像向量化和相似度搜索系统,复用了现有的图像向量化方式。 + +## 功能特性 + +- **图像向量化**: 使用open-clip模型将图像编码为高维向量 +- **高效搜索**: 基于Faiss的向量相似度搜索 +- **文本搜索**: 支持通过文本描述搜索相关图像 +- **批量处理**: 支持批量添加和索引图像 +- **元数据管理**: 保存图像文件信息和自定义元数据 +- **重复检测**: 自动检测相似或重复的图像 + +## 核心组件 + +### 1. VectorDatabase 类 +- 核心向量数据库实现 +- 图像编码和向量存储 +- 相似度搜索功能 + +### 2. VectorDBManager 类 +- 高级管理接口 +- 目录批量索引 +- 重复图片检测 +- 元数据导出 + +## 安装依赖 + +```bash +pip install -r requirements_vector_db.txt +``` + +主要依赖: +- torch: 深度学习框架 +- open-clip-torch: CLIP模型 +- faiss-cpu/faiss-gpu: 向量搜索引擎 +- Pillow: 图像处理 +- numpy: 数值计算 + +## 快速开始 + +### 基本使用 + +```python +from vector_database import VectorDatabase + +# 初始化数据库 +db = VectorDatabase(db_path='my_vector_db') + +# 添加单张图片 +image_id = db.add_image('path/to/image.jpg', + metadata={'category': 'animals', 'tags': ['cute', 'dog']}) + +# 文本搜索 +results = db.search_by_text('cute dog', k=5) +for result in results: + print(f"{result['image_path']}: {result['similarity']:.3f}") + +# 图片相似搜索 +similar = db.search_by_image('query_image.jpg', k=5) +``` + +### 使用管理工具 + +```python +from vector_db_utils import VectorDBManager + +# 创建管理器 +manager = VectorDBManager(db_path='my_vector_db') + +# 索引整个目录 +stats = manager.index_directory('images_folder/') +print(f"索引了 {stats['indexed']} 张图片") + +# 搜索 +results = manager.search_by_description('beautiful landscape', k=10) +``` + +### 运行演示 + +```bash +python demo_vector_db.py +``` + +## 技术实现 + +### 向量化方法 +- 使用ViT-B-32模型(默认)提取图像特征 +- 特征向量维度: 512 +- L2归一化处理 +- 支持本地文件和网络URL + +### 存储结构 +``` +vector_db/ +├── index.faiss # Faiss向量索引文件 +└── metadata.pkl # 图像元数据和配置 +``` + +### 相似度计算 +- 使用内积(Inner Product)计算向量相似度 +- Faiss IndexFlatIP索引类型 +- 实时搜索,无需预计算 + +## API 参考 + +### VectorDatabase + +#### 初始化 +```python +VectorDatabase( + model_name='ViT-B-32', # CLIP模型名称 + pretrained='laion2b_s34b_b79k', # 预训练权重 + db_path='vector_db', # 数据库存储路径 + dimension=512 # 向量维度 +) +``` + +#### 主要方法 +- `add_image(image_path, metadata)`: 添加单张图片 +- `add_images_batch(image_paths, metadatas)`: 批量添加图片 +- `search_by_text(query_text, k)`: 文本搜索 +- `search_by_image(query_image_path, k)`: 图片搜索 +- `get_stats()`: 获取数据库统计信息 + +### VectorDBManager + +#### 高级功能 +- `index_directory(directory_path)`: 索引目录 +- `find_duplicates(similarity_threshold)`: 查找重复图片 +- `export_metadata(output_file)`: 导出元数据 + +## 性能优化 + +### GPU加速 +```python +# 自动检测并使用GPU +db = VectorDatabase() # 自动使用CUDA如果可用 +``` + +### 批量处理 +```python +# 批量添加比逐个添加更高效 +image_paths = ['img1.jpg', 'img2.jpg', ...] +db.add_images_batch(image_paths) +``` + +### 内存优化 +- 模型加载后复用 +- 向量计算使用float32精度 +- 支持大规模图片集合 + +## 注意事项 + +1. **模型下载**: 首次运行会下载CLIP模型(约1GB) +2. **内存使用**: 大量图片需要足够内存存储向量 +3. **删除限制**: Faiss不支持直接删除,需要重建索引 +4. **文件路径**: 确保图片路径在添加后不会改变 + +## 扩展功能 + +### 自定义模型 +```python +# 使用不同的CLIP模型 +db = VectorDatabase( + model_name='ViT-L-14', + pretrained='openai' +) +``` + +### 高级搜索 +```python +# 组合搜索结果 +text_results = db.search_by_text('query', k=20) +image_results = db.search_by_image('query.jpg', k=20) +# 合并和排序结果... +``` + +## 故障排除 + +### 常见问题 +1. **Faiss安装失败**: 尝试使用conda安装 +2. **CUDA内存不足**: 减少批量大小或使用CPU +3. **图片加载失败**: 检查文件格式和路径 + +### 日志调试 +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` \ No newline at end of file diff --git a/spx-algorithm/resource/vector_db/__pycache__/vector_database.cpython-312.pyc b/spx-algorithm/resource/vector_db/__pycache__/vector_database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4e82d858fb64dcb518e25b57fb67c26c5290c2d GIT binary patch literal 18146 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

}o>F}YOlsew>gbaihzFCI zTf)f7F);I3@NHFB+glZV0q;x6fZ8tdvy_ zh6{$%xt$&GfehU6>){k|7yh8tz)Pa4PEQwHH|E97zk)_Md504&{qaYN{SlWN*XvUL{+6Qvdf$ld9G(_~pA${=#{g3)GREPst5G-0YWvC*^K4Zuj zqKE278ipGpITfLtiZC!dw5SiO4Owaf%dXL3%chW~q~9>KpD;|*6;&780?Q^}c)#Y_ z>KnD!8^YBsf2aUv$%4$11(>DHQLt1nBOSvX|Cq60rm$qR?evoAa^#%K>!QUa6ZF`Y zdunB2_MCVg#LPmmA@v2#(0z;os!4Z>rQ3HMF^~`Gq3~ zhY#LMQ)lYtD798cI0VAr&$Ei)oD)9rDgk1 z#E2v>Iy-tPHuOvWQzCvSVtM|1Bw|8tZb~wwZ}pP8*Xw*D9RHzpg<*6{fI*+TTh*OOhNtG=7wgM zC-@`jdtTz$l`tj=z?S5MlGXR$I{#KV`4D{mf$hKjh9ABQ=g-9KPhOk(?U~rn>u``; zh`Iwf3*;5ZP0_?HPaeHdr2>}~JP!B{&y9tUiC_oNdlfy@y9oC|PhkR{Aa*Bu$Iu%D z&y55N554ex9WMLB*Foo<%2)9;{Z}Y=i~`|qur|9W|Ga zcZ_vJ%(WqN?H|l_11{slyx$Elp+V-wq zdyv!ej46Cj?1XUf2`pA;_>p=)HyB z7*I%Tl-q za!*^TG)GG;b8>jzf~OLmP^8E_r^b+mDzV(hGGA_@4a)2!( zGud=4=UUq}(}yjgy6q$__^d7X*KMI^nfq8`u8F3$(9QHc`EsSv-xSC>-#kY_FuGtV z#Bu{|lXZau@0&x*9uF;OoWnxVolggMw+6reSE1du(9XZUk7@T9IzzcS0HcM+B>%X! z=SIa1cW}?MH;(+5>d=^-xuLnfA+%vPDK=ML0;Ab` zsr`KW90kG2XUOB>CLwQbgI1{CdwhY->E?jtm6xkRm$MW*0ns@Lwv}!PI zIeK>V5DW?Pw}X7N%l-xv3C$+^=R=HI(8E2&X&&RZ{|5GBh{Bel+{eM2lhZW)1*QFh zO8bIJhrmBm%fi&MFR1JkCEZoJvmTexbU7 j$$+APn`lV4v!ga3a4n>~z{ literal 0 HcmV?d00001 diff --git a/spx-algorithm/resource/vector_db/__pycache__/vector_db_utils.cpython-312.pyc b/spx-algorithm/resource/vector_db/__pycache__/vector_db_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a1fd4b9bd8132a276aed2245ffe5d24cd248a1e2 GIT binary patch literal 9205 zcmcIpdvFs+n(vWDZ`qP<+49>m#t=Cmhes0d5eS4wHW23lgv3Xpl{I4_BTLTAh=)t2 zoIP^L7qcjp2WD}Co!i}rg@AYW?&1KKkgd8ZQuoJ=BC~b|u8yihHm ziK{8RkS1v>qKi5k7t<^KB`}YTGSMYx(FZDdwFII|D02l2qtL;vu$pba<>AF#r~<0+ zqD9$HnaIluxt%VPd7VX6(Fba|2As>bqE|r%ZRH|$FQfCECk{n5`bp z)9ztuZ}twv3jq!D{|4D7!YNnG&BjK($b{3FdeO< zNm}2b!nF>qC^`dGN*RF42qM%0Wfn!6$^_g@KxO_xssf-g1Ba~fu#&>#WLp8O!vbR$ z_K}noUmU88Dxxjo%!}q0=O?LRptBw&op!;JK9?=edV(Gw&4lqiOU2%vIdN+0z0s+Y zmr_^WnZ7hK9sA4Fr@uwg{-8hH zyfx$vchNzPZEg?y0~F0Pv)xBldIG)> zs3;D>#)iAQLkve#Zu$sE2U)oMte|CRF3beIiU?pV20C1ycaWm6RDGlm!0scWSH(fI zlsXmR$SnwUYBJyQS40sgTf=GP)=q=`l_@DzgjfhJl$s(b&5&w0@}XWemxf4~M}Cu; z!EmK6r27FjScLY&4tmL6O|Lehg_Z+Q(>-AI1zGIUpAlWBWpS$vit>#6;VgRfy@rS( zqB?|FVqo0^W-40DGeSmmGC=kkBkInQJTzJ0J^qO^<7RY&Q? zKf@5Y8)c2gw<;%5Mh?S*m?8>11N@q@!5ZXaim2h7Pr*qTAF%r>nO~-iC_8(=*qY23 z`RhlIt&>M&${#(pJ~KxCiWuR}rC$MM9wK|q5mR2NndN&zpnsY zg-rR#1JVm{tHmMl$^mLTT|PhqXn*(e)W92f-uF-3z4~`k{pVA^eJ>R|smzFnDwP*S z_{&if={;P1XX?YD>8t1NT^dp7o!TDK)!FUq(Lhf7pwb=0d3#qkG;#ip4ydq)d|~{= z@1|HJQZ55yscgad+%D*?hjeHjuj3 z2Rc>SyvyYRqewXLYV}6nE`b7U|j$2zfmLcB4xfvjbSL7#{DT|LjA2Y0HRxaC3a~`Dfqt zjM)E+#Y*pIx+3DQ;NPhc&yoS_6EWpD!w|?|>NKiClx)Nt^E9$Ta0`Rib7(ua@c#Ix7I+2bi0thNNJhoap6E-=zqce{u49uU-Q zxXW3@;Po=t}Tu~70yJ3Grsh4)A^k?O*!N=7j)p2TcYqEZQ?3qbh-Gpsf!nSO<=caA-oB>tT z&Z^Op^>c;DT7_5E+TY68Y`=bt-~JL`wQt<~@?>4ZC^7cJjiN-!PTsupuDW}JxPCV*ywn~w+^TGhK6AIa_S}v$J0_}EB&t`$+mhAmZr9YE z+jnN)IrkYi)N2;krTuoy!v)&`CwJDv~kj2J6TpeS+RW1q%AT=HTSK^U=g<$9Ns&;gSV|1*RPzk*rH|z zUlqaldYER?rYdo8fjtUVsAP|}zz2qrPAGFzXSM~K?gTeOCF@VnR7Ch6phJQ3sn`%a zhbD$vxxXESe}1k*%X z*!Re^Fe6i}Eo6~C13Uf!k-`ng+Qj#YbRk)LUXeB?ERDkl7l_oGtJ+2X@G%(vKY5R` zy(+#%Q5FCE)6}W=6=(eJ7b8<|T$b;)0W_UkR3=VBywxmTMEWKd;ZCfLu!aPexg*q5 zpL>fJk%aJe@w|QLj-^Dpzm9~(k+dw~^-I!sHx%Z&!fP0IZ|)_h4ifuC?AV` zh%6xj2!H+#W+b`;5=?)4Et)!Yd8V)b+iU#_7yQDNN?t%#mXP}7bn4A9g&54$Lgg-s zH&)O}wivSqb|l*8tpahlM-%2cR<479qn-|WLljthGuTev4h*!|C<=0d86q=bhIV^c zuiqcW=3{2oPk$$!3HSnFb{a$#z&wX%*bJGV0qe30th!D%6cor1Se0Pgg}b^L>`pL` zK<3m*>fr@UZjr4xhzH@okf4{WJwFA*;AJs?j;V+a7T-dAIFQl?oHW2KGgxfE1xfyJ$y2t0V$ZWr5w%;x)jU60bGitwCw0`V`NvrL&;cdfU zb9~k43)lC?4CB_F->Fe?%U!U-cJ}Y&D_4y^JX*t-ZyGm0dB;-A>ubL=KsR5s{!c6p zuYI|!Y)h^B54D;t%XJVqleDFuJL(2=n`RG$0@MO%z+o*B8vYvV zGbU}r+0!_4K_;ld*<@DXY6e*h60&%4zmW=qtLyb};A0kaGa($>fUvWlVi_FuXMT!_ z3Ktrof<+nR_A#MwHv~nbcgNx@jL$?&X$0y3xO6Kc*u@BxLvRv!u+xwM0@+YmdDM8j zyy~nbT5zX!iG)V^5XWL4d?I0aXwq5^z6}t9k2O)VHc_*7bkEp|#G^ZItmbRhCTm)w ztm)lK(}$XXKHl(aMqdytRnI-J^56sgM_%)hx#__|7mG?-&eS3=aA; z@KE7i_=6nt6I^{3XV2pdo0t$>2!uQw(~TRJ;0zlJq9hqCTrtBREDj|eN8gYS&5?S| z>S6Uff&#pamW^}jCp4vT?<|61a^QE&mAL7n#V@f zvj~bYskpx9#?l)szh~c#BVV}^+g_Q+eRGwVu0*1fitE&kwfytD`Modm&%gZD#>DnE zk#e?DrD+&m8&}77$Mqu{=Mhxr^yQkm_?lS+#n{p@_WDy}M?Q1SW(a0LDN!yLif z&E*KVtqK1e79m1sDtb0`^~&@wuiX9oEyaWsd&F!ZL}#Bqn2UdVeCkRpZ`4^Fy9kjL z{>pEWsY@Tiydt$=Wj$Tp@TQc9$Y2X2jD;-45wquOabyJ;rO-22dV1-VEQ2A;a-6Ni z*+V#U;_MNe;TH(L;!!97fZN?&(oeR`Q_!$53Q*={XuxK7dSSBV%Jc%|WlY@#Ss%J@ zL&fzMi!a;H+b>t2uO1EXTVF~#_l+05-1iKGg7n4{hu=7S;@BI^#liUShuXEK0~_k{-EdYg`1}PO4(#GEt(H654P`D6;L@WLoHQg~(M(q=Z z%7meEa9z@{v`_n;)~g~a=a881(i9VAallF_#zOPBm~BF&Iok8Jy5bIL@7pm!+7qOG z(049yCUBE{7|e4^W3q5r-wx1^=8_51(u8Sg(zL8^>o=r&MrQ>8H^y|&h z)`YHV(DSvf?oM&VL~(PXxOueTdJ$jToGgASs{2M?z+0D&?2g<2djD|iXcJ%f#JGOr zt+JZv_HWEZu`RJfgPjSxGv1W2KgyRqHrhT`cHNv<|LhGfzwSl8c=t{7o;fwL*UlP{ M-Z5K&NC|@f8!hNqIsgCw literal 0 HcmV?d00001 diff --git a/spx-algorithm/resource/vector_db/batch_example_db/index.faiss b/spx-algorithm/resource/vector_db/batch_example_db/index.faiss new file mode 100644 index 0000000000000000000000000000000000000000..99335b2fcb25d57c608a1fec5429ad8c7e13561c GIT binary patch literal 6189 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 literal 0 HcmV?d00001 diff --git a/spx-algorithm/resource/vector_db/cute.svg b/spx-algorithm/resource/vector_db/cute.svg new file mode 100644 index 000000000..9eac5733d --- /dev/null +++ b/spx-algorithm/resource/vector_db/cute.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spx-algorithm/resource/vector_db/cute2.svg b/spx-algorithm/resource/vector_db/cute2.svg new file mode 100644 index 000000000..411a5835b --- /dev/null +++ b/spx-algorithm/resource/vector_db/cute2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spx-algorithm/resource/vector_db/database_metadata.json b/spx-algorithm/resource/vector_db/database_metadata.json new file mode 100644 index 000000000..447b88b28 --- /dev/null +++ b/spx-algorithm/resource/vector_db/database_metadata.json @@ -0,0 +1,42 @@ +{ + "database_stats": { + "total_images": 3, + "dimension": 512, + "model_name": "ViT-B-32", + "pretrained": "laion2b_s34b_b79k", + "db_path": "/Users/qiniu/Documents/builder/spx-algorithm/resource/vector_db/demo_vector_db", + "device": "cpu" + }, + "images": { + "0": { + "image_path": "/Users/qiniu/Documents/builder/spx-algorithm/resource/vector_db/dog.svg", + "metadata": { + "filename": "dog.svg", + "directory": "/Users/qiniu/Documents/builder/spx-algorithm/resource/vector_db", + "file_size": 15505, + "relative_path": "dog.svg" + }, + "added_at": "2025-08-26T13:59:58.374985" + }, + "1": { + "image_path": "/Users/qiniu/Documents/builder/spx-algorithm/resource/vector_db/cute.svg", + "metadata": { + "filename": "cute.svg", + "directory": "/Users/qiniu/Documents/builder/spx-algorithm/resource/vector_db", + "file_size": 37641, + "relative_path": "cute.svg" + }, + "added_at": "2025-08-26T13:59:58.546975" + }, + "2": { + "image_path": "/Users/qiniu/Documents/builder/spx-algorithm/resource/vector_db/cute2.svg", + "metadata": { + "filename": "cute2.svg", + "directory": "/Users/qiniu/Documents/builder/spx-algorithm/resource/vector_db", + "file_size": 59391, + "relative_path": "cute2.svg" + }, + "added_at": "2025-08-26T13:59:58.727322" + } + } +} \ No newline at end of file diff --git a/spx-algorithm/resource/vector_db/demo_vector_db.py b/spx-algorithm/resource/vector_db/demo_vector_db.py new file mode 100644 index 000000000..704b3032d --- /dev/null +++ b/spx-algorithm/resource/vector_db/demo_vector_db.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +向量数据库演示脚本 +""" +import os +import sys +import logging +from vector_db_utils import VectorDBManager, create_sample_database + +# 设置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +def main(): + print("=== 向量数据库演示 ===\n") + + # 获取当前脚本所在目录 + current_dir = os.path.dirname(os.path.abspath(__file__)) + + try: + # 创建数据库管理器 + print("1. 初始化向量数据库...") + db_manager = VectorDBManager(db_path=os.path.join(current_dir, 'demo_vector_db')) + + # 索引当前目录的图片 + print("2. 索引当前目录的图片...") + stats = db_manager.index_directory(current_dir) + print(f" 索引结果: {stats}") + + # 显示数据库信息 + print("\n3. 数据库信息:") + info = db_manager.get_database_info() + for key, value in info.items(): + print(f" {key}: {value}") + + if info['total_images'] == 0: + print("\n注意: 当前目录没有找到图片文件,请添加一些图片文件后重试") + return + + # 文本搜索示例 + print("\n4. 文本搜索示例:") + search_queries = [ + "cute animal", + "dog", + "cartoon", + "colorful image" + ] + + for query in search_queries: + print(f"\n 搜索: '{query}'") + results = db_manager.search_by_description(query, k=3) + if results: + for i, result in enumerate(results, 1): + filename = result['metadata']['filename'] + similarity = result['similarity'] + print(f" {i}. {filename} (相似度: {similarity:.3f})") + else: + print(" 未找到匹配结果") + + # 图片相似搜索示例(如果有多张图片) + if info['total_images'] > 1: + print("\n5. 图片相似搜索示例:") + + # 获取第一张图片作为查询 + first_image_path = None + for image_id, data in db_manager.db.metadata.items(): + first_image_path = data['image_path'] + break + + if first_image_path: + print(f" 使用图片: {os.path.basename(first_image_path)}") + similar_results = db_manager.search_similar_images(first_image_path, k=5) + for i, result in enumerate(similar_results, 1): + filename = result['metadata']['filename'] + similarity = result['similarity'] + print(f" {i}. {filename} (相似度: {similarity:.3f})") + + # 查找重复图片 + print("\n6. 查找重复图片:") + duplicates = db_manager.find_duplicates(similarity_threshold=0.9) + if duplicates: + print(f" 找到 {len(duplicates)} 组相似图片:") + for i, group in enumerate(duplicates, 1): + print(f" 组 {i}:") + for item in group: + filename = item['metadata']['filename'] + similarity = item['similarity'] + print(f" - {filename} (相似度: {similarity:.3f})") + else: + print(" 未找到重复图片") + + # 导出元数据 + print("\n7. 导出数据库元数据...") + export_path = os.path.join(current_dir, 'database_metadata.json') + db_manager.export_metadata(export_path) + print(f" 元数据已导出到: {export_path}") + + print("\n=== 演示完成 ===") + + except ImportError as e: + if "faiss" in str(e): + print("错误: 缺少faiss库") + print("请安装faiss库:") + print(" CPU版本: pip install faiss-cpu") + print(" GPU版本: pip install faiss-gpu") + else: + print(f"导入错误: {e}") + sys.exit(1) + except Exception as e: + print(f"运行错误: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/spx-algorithm/resource/vector_db/demo_vector_db/index.faiss b/spx-algorithm/resource/vector_db/demo_vector_db/index.faiss new file mode 100644 index 0000000000000000000000000000000000000000..99335b2fcb25d57c608a1fec5429ad8c7e13561c GIT binary patch literal 6189 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 + + + + + + + + diff --git a/spx-algorithm/resource/vector_db/example_db/index.faiss b/spx-algorithm/resource/vector_db/example_db/index.faiss new file mode 100644 index 0000000000000000000000000000000000000000..c65dbecc4bb19f83074a57bcbf5227eba1bb7e07 GIT binary patch literal 2093 zcmXwvd05W*8iz~NOLL}$v}+*|MQJ(S-}5w1w22n7w7AkJOGrgzNs%@!lc=U0r8LIW zMBDp)z6ph*X|xQLqs2BPq>zX?=bCfwe?Is1xo*cWJ4dMrQc}`V|3~qE^7sDz|0nn_ za`QZ}q)^&Aj|cEvu@RU#Jr=?@w!p=zb=di%0gKjuLv3IFpc5v`Im|c>fllgd_u+WH z%Ebi(%_7OQw3i!t7Ld1^F6R|RbS5VkKE7Ch&l|JI$ub6HItE#H{UC;Eo}!XsDL(dN z9Vo8$;1M$ET+4eBlm7gcW;t2I@USXBdu|8V^j84$m0*%Y7rdL5uv}IHR%AKUNM8=c zeQb=KQ>}#T5AW$=*B5nJz5TV)`7lP;je+WQdRWDEDa`YFG` zj{DeG)ActV)az=3?>ir0a@7ggFsKeuP8IC9@;fMx%>a|{N_NA6RX z2t9IBFy_2K4Si{n#rJgh+mrWE$^ICt^}Y`af;8~#p5H*HW)kj-v&LEjC5X^d;?s0b zljMj9d%L6P`gj)=C``nl%tUc{h!tKtbQEV~EW<6LounI+!ShsK3I+vkbWKqiyH}mW zBFi%N&$LQjej^I!Upy{$Ncah-wz#5g`$9}JwV@T4vc;QK!mTG4tK+o5Gt~bsguC8K zg?kTkVQt(;&<_6rY=;_!K(}Zp9%_|DHAJ#+!XgQ0orPsp^?c9PVeH>2%iVWs;4zY? zIo17OU>}XWc@Ff{G=iITdRcGiapLopmxH6eDG3usg=6zuh54IWsnhj1#d$nq3gh-< z^a-?O#pg4mgT*D9-_lnG zF!qJ+#;AST7~I(f2SbP8={i$xEEhoPw^s5#=}6&IV<;-O8Dp@CG?aT>g^p`6EM{~8 z&$er!$(0@SE-bfN??5RlZm<>d0vN1P(Ixu_SE(#24P*ZB#2a?c;fYQX{9-s2H>m1y z1!Wi9c=0hv-85#R_7ocL-3V>3UQ^1n{iI!(S-tev)x6PA0~f}}3x1%7#~z0WlNPs% z^*_I08V96nH>q5qq0}i%aa9%gof#A41LNU$7Rl2ehS$nQFQvsDKQHcU_7w)Ms_-Xq zMfA3RCRb0WV(Lx5;J897v!9X&!OPUZveOk?`Bf+n@x_8_PK%ri__3G?wIUn@snKY@ zlp^`WHPK*^eI9tREY-`{QQhWibnCVw23aJ~l7!iqe)$A^d)G>k_B+MT{g#B=7j%Kv zpAw4Q<^#*SHQsiNa1-*up7;%_Co!bKYKjp!OsTxeHumt>1 zzagVfS#a5yk>#3r7*+M*FZ%XT%$S^Tq%f2xzI#h{rCV^~@F~vBM0B~goOYZu6DQ2+ zpt80&3brdp@4a2@yMR};y-j?4LYRsy6dPFf-zTFhXKp zQsv2~U8&*rQxq}h$y`3$_c&S@23gy`u_NUYVq@Cp@u>SO7JWHR618fW2K+?0_pTTB zKa|DtwDmZR>=l}VC-EmaML62+K`Vo0G3Txv_BE#B(z9o2kxKx&rA@$%JfB(yRrt*_ zk?hO5(>T2$9n3vQm^*MAG8fMVC9^#6J*0ph8BJ(2dQe_I#LjXl__<7PN%wjSQGk;9<-Huy62BTU&eg4>Nkv2j#{vLQ|MbVwV! z269;jKBB7Jg($Pu8)fFHK;k?vJoqmqG;dDfcQQ^=*wIMzcOHPD(q^VHG(i4~)Pzz= zJvFDj5Go8tXtul$(^;PkHo7kOSoRT#W`wabEt+tp_yU F`U~#9;Z6Vm literal 0 HcmV?d00001 diff --git a/spx-algorithm/resource/vector_db/example_db/metadata.pkl b/spx-algorithm/resource/vector_db/example_db/metadata.pkl new file mode 100644 index 0000000000000000000000000000000000000000..041fc668451b772d1685c826e87907610eb0962e GIT binary patch literal 297 zcmaKmu};G<6h#y05b_BO%q*$X6iU(&F(6fJg*9^Rr^Hem2m86ALnS`I8IdBXRN|rGYigmD}WtAps}qyFyRrF?$}YEUgiWxi1ibN#e9l}rzzU)-0>mf zQi5zbQn;_m>bWTEqT1|NPusHIu9{`tG}XE!?;@sbh4CYTrCl2Cq>5xtnUOw{n~-ZG eVQ5FLjU;YDt_GWAV literal 0 HcmV?d00001 diff --git a/spx-algorithm/resource/vector_db/requirements_vector_db.txt b/spx-algorithm/resource/vector_db/requirements_vector_db.txt new file mode 100644 index 000000000..85d203e37 --- /dev/null +++ b/spx-algorithm/resource/vector_db/requirements_vector_db.txt @@ -0,0 +1,8 @@ +# 向量数据库依赖库 +torch>=1.9.0 +open-clip-torch +Pillow>=8.0.0 +numpy>=1.20.0 +faiss-cpu>=1.7.0 # 或者使用 faiss-gpu 如果有GPU支持 +requests>=2.25.0 +cairosvg>=2.5.0 # SVG图片支持 \ No newline at end of file diff --git a/spx-algorithm/resource/vector_db/simple_usage_example.py b/spx-algorithm/resource/vector_db/simple_usage_example.py new file mode 100644 index 000000000..82a6e14d0 --- /dev/null +++ b/spx-algorithm/resource/vector_db/simple_usage_example.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +简单的向量数据库使用示例 +展示如何在其他项目中集成和使用向量数据库 +""" +import os +from vector_database import VectorDatabase +from vector_db_utils import VectorDBManager + +def example_basic_usage(): + """基本使用示例""" + print("=== 基本使用示例 ===") + + # 1. 创建向量数据库实例 + db = VectorDatabase(db_path='example_db') + + # 2. 添加单张图片 + current_dir = os.path.dirname(os.path.abspath(__file__)) + image_path = os.path.join(current_dir, 'cute.svg') + + if os.path.exists(image_path): + image_id = db.add_image(image_path, metadata={'category': 'cute', 'type': 'svg'}) + print(f"添加图片成功,ID: {image_id}") + + # 3. 文本搜索 + results = db.search_by_text('cute animal', k=3) + print(f"搜索 'cute animal' 的结果:") + for result in results: + print(f" - {os.path.basename(result['image_path'])}: {result['similarity']:.3f}") + + print() + +def example_batch_processing(): + """批量处理示例""" + print("=== 批量处理示例 ===") + + # 使用管理器进行批量操作 + manager = VectorDBManager(db_path='batch_example_db') + + # 索引当前目录 + current_dir = os.path.dirname(os.path.abspath(__file__)) + stats = manager.index_directory(current_dir) + print(f"索引统计: 总计 {stats['total']} 个文件,成功 {stats['indexed']} 个") + + # 搜索示例 + results = manager.search_by_description('dog', k=2) + print(f"搜索 'dog' 的结果:") + for result in results: + print(f" - {result['metadata']['filename']}: {result['similarity']:.3f}") + + print() + +def example_integration_with_existing_service(): + """与现有服务集成的示例""" + print("=== 与现有服务集成示例 ===") + + # 模拟与现有ImageSearchService的集成 + print("1. 可以复用相同的模型配置") + print(" - 模型: ViT-B-32") + print(" - 预训练权重: laion2b_s34b_b79k") + print(" - 支持相同的图片格式(包括SVG)") + + print("\n2. 向量数据库的优势:") + print(" - 预计算图片向量,搜索更快") + print(" - 支持大规模图片集合") + print(" - 持久化存储,无需重复计算") + + print("\n3. 使用场景:") + print(" - 图片库管理系统") + print(" - 相似图片推荐") + print(" - 重复图片检测") + print(" - 基于内容的图片搜索") + + print() + +def main(): + """主函数""" + try: + example_basic_usage() + example_batch_processing() + example_integration_with_existing_service() + + print("=== 所有示例运行完成 ===") + + except Exception as e: + print(f"运行出错: {e}") + print("请确保已安装所需依赖: pip install -r requirements_vector_db.txt") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/spx-algorithm/resource/vector_db/vector_database.py b/spx-algorithm/resource/vector_db/vector_database.py new file mode 100644 index 000000000..46b9ac8ae --- /dev/null +++ b/spx-algorithm/resource/vector_db/vector_database.py @@ -0,0 +1,417 @@ +import os +import pickle +import logging +import io +from typing import List, Dict, Any, Optional +import numpy as np +import torch +import open_clip +from PIL import Image +try: + import faiss +except ImportError: + faiss = None +try: + import cairosvg +except ImportError: + cairosvg = None +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class VectorDatabase: + """基于open-clip的图像向量数据库""" + + def __init__(self, + model_name: str = 'ViT-B-32', + pretrained: str = 'laion2b_s34b_b79k', + db_path: str = 'vector_db', + dimension: int = 512): + """ + 初始化向量数据库 + + Args: + model_name: CLIP模型名称 + pretrained: 预训练权重 + db_path: 数据库存储路径 + dimension: 向量维度 + """ + self.model_name = model_name + self.pretrained = pretrained + self.db_path = db_path + self.dimension = dimension + self.device = "cuda" if torch.cuda.is_available() else "cpu" + + # 模型相关 + self.model = None + self.preprocess = None + self.tokenizer = None + + # 数据库相关 + self.index = None + self.metadata = {} + self.id_counter = 0 + + self._init_model() + self._init_database() + + def _init_model(self): + """初始化CLIP模型""" + try: + self.model, _, self.preprocess = open_clip.create_model_and_transforms( + self.model_name, pretrained=self.pretrained + ) + self.tokenizer = open_clip.get_tokenizer(self.model_name) + self.model = self.model.to(self.device) + self.model.eval() + logger.info(f"模型加载成功: {self.model_name} on {self.device}") + except Exception as e: + logger.error(f"模型加载失败: {e}") + raise + + def _init_database(self): + """初始化向量数据库""" + if faiss is None: + raise ImportError("faiss库未安装,请运行: pip install faiss-cpu 或 pip install faiss-gpu") + + os.makedirs(self.db_path, exist_ok=True) + + # 初始化Faiss索引 + self.index = faiss.IndexFlatIP(self.dimension) # 内积相似度 + + # 加载已有数据 + self._load_database() + + def _save_database(self): + """保存数据库到文件""" + try: + # 保存索引 + index_path = os.path.join(self.db_path, 'index.faiss') + faiss.write_index(self.index, index_path) + + # 保存元数据 + metadata_path = os.path.join(self.db_path, 'metadata.pkl') + with open(metadata_path, 'wb') as f: + pickle.dump({ + 'metadata': self.metadata, + 'id_counter': self.id_counter, + 'dimension': self.dimension, + 'model_name': self.model_name, + 'created_at': datetime.now().isoformat() + }, f) + + logger.info(f"数据库已保存到: {self.db_path}") + except Exception as e: + logger.error(f"保存数据库失败: {e}") + raise + + def _load_database(self): + """从文件加载数据库""" + try: + index_path = os.path.join(self.db_path, 'index.faiss') + metadata_path = os.path.join(self.db_path, 'metadata.pkl') + + if os.path.exists(index_path) and os.path.exists(metadata_path): + # 加载索引 + self.index = faiss.read_index(index_path) + + # 加载元数据 + with open(metadata_path, 'rb') as f: + data = pickle.load(f) + self.metadata = data['metadata'] + self.id_counter = data['id_counter'] + + logger.info(f"数据库加载成功,包含 {len(self.metadata)} 个向量") + else: + logger.info("未找到已有数据库,创建新的数据库") + except Exception as e: + logger.error(f"加载数据库失败: {e}") + # 重新初始化 + self.index = faiss.IndexFlatIP(self.dimension) + self.metadata = {} + self.id_counter = 0 + + def _encode_image(self, image_path: str) -> Optional[np.ndarray]: + """ + 编码单张图片为向量,支持SVG格式 + + Args: + image_path: 图片路径 + + Returns: + 图片特征向量,失败返回None + """ + try: + if not os.path.exists(image_path): + logger.warning(f"图片文件不存在: {image_path}") + return None + + logger.info(f"处理本地图片: {image_path}") + + # 根据文件扩展名选择处理方式 + _, ext = os.path.splitext(image_path.lower()) + + if ext == '.svg': + # 处理SVG文件 + if cairosvg is None: + logger.error("SVG支持需要安装cairosvg库: pip install cairosvg") + return None + + png_data = cairosvg.svg2png( + url=image_path, + output_width=224, + output_height=224, + background_color='white' + ) + image = Image.open(io.BytesIO(png_data)).convert('RGB') + else: + # 处理其他格式图片 + image = Image.open(image_path).convert('RGB') + + logger.info(f"图片尺寸: {image.size}, 模式: {image.mode}") + + # 预处理图片 + image_tensor = self.preprocess(image).unsqueeze(0).to(self.device) + + with torch.no_grad(): + # 编码图片 + image_features = self.model.encode_image(image_tensor) + # 归一化 + image_features /= image_features.norm(dim=-1, keepdim=True) + + return image_features.cpu().numpy().astype('float32') + + except Exception as e: + logger.error(f"编码图片失败 {image_path}: {e}") + return None + + def _encode_text(self, text: str) -> Optional[np.ndarray]: + """ + 编码文本为向量 + + Args: + text: 文本内容 + + Returns: + 文本特征向量,失败返回None + """ + try: + text_tokens = self.tokenizer([text]).to(self.device) + + with torch.no_grad(): + text_features = self.model.encode_text(text_tokens) + text_features /= text_features.norm(dim=-1, keepdim=True) + + return text_features.cpu().numpy().astype('float32') + + except Exception as e: + logger.error(f"编码文本失败 {text}: {e}") + return None + + def add_image(self, image_path: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[int]: + """ + 添加图片到向量数据库 + + Args: + image_path: 图片路径 + metadata: 图片元数据 + + Returns: + 图片ID,失败返回None + """ + vector = self._encode_image(image_path) + if vector is None: + return None + + # 添加到索引 + self.index.add(vector) + + # 保存元数据 + image_id = self.id_counter + self.metadata[image_id] = { + 'image_path': image_path, + 'metadata': metadata or {}, + 'added_at': datetime.now().isoformat() + } + self.id_counter += 1 + + # 保存数据库 + self._save_database() + + logger.info(f"图片已添加到数据库: {image_path} (ID: {image_id})") + return image_id + + def add_images_batch(self, image_paths: List[str], + metadatas: Optional[List[Dict[str, Any]]] = None) -> List[Optional[int]]: + """ + 批量添加图片到向量数据库 + + Args: + image_paths: 图片路径列表 + metadatas: 图片元数据列表 + + Returns: + 图片ID列表 + """ + if metadatas is None: + metadatas = [None] * len(image_paths) + + results = [] + vectors = [] + valid_indices = [] + + # 批量编码 + for i, (image_path, metadata) in enumerate(zip(image_paths, metadatas)): + vector = self._encode_image(image_path) + if vector is not None: + vectors.append(vector) + valid_indices.append(i) + + # 准备元数据 + image_id = self.id_counter + len(vectors) - 1 + self.metadata[image_id] = { + 'image_path': image_path, + 'metadata': metadata or {}, + 'added_at': datetime.now().isoformat() + } + results.append(image_id) + else: + results.append(None) + + if vectors: + # 批量添加到索引 + vectors_array = np.vstack(vectors) + self.index.add(vectors_array) + self.id_counter += len(vectors) + + # 保存数据库 + self._save_database() + + logger.info(f"批量添加 {len(vectors)} 张图片到数据库") + + return results + + def search_by_image(self, query_image_path: str, k: int = 10) -> List[Dict[str, Any]]: + """ + 通过图片搜索相似图片 + + Args: + query_image_path: 查询图片路径 + k: 返回结果数量 + + Returns: + 相似图片列表 + """ + query_vector = self._encode_image(query_image_path) + if query_vector is None: + return [] + + return self._search_by_vector(query_vector, k) + + def search_by_text(self, query_text: str, k: int = 10) -> List[Dict[str, Any]]: + """ + 通过文本搜索相似图片 + + Args: + query_text: 查询文本 + k: 返回结果数量 + + Returns: + 相似图片列表 + """ + query_vector = self._encode_text(query_text) + if query_vector is None: + return [] + + return self._search_by_vector(query_vector, k) + + def _search_by_vector(self, query_vector: np.ndarray, k: int) -> List[Dict[str, Any]]: + """ + 通过向量搜索 + + Args: + query_vector: 查询向量 + k: 返回结果数量 + + Returns: + 搜索结果列表 + """ + if self.index.ntotal == 0: + logger.warning("数据库为空") + return [] + + k = min(k, self.index.ntotal) + similarities, indices = self.index.search(query_vector, k) + + results = [] + for i, (similarity, idx) in enumerate(zip(similarities[0], indices[0])): + if idx in self.metadata: + result = { + 'id': int(idx), + 'similarity': float(similarity), + 'rank': i + 1, + **self.metadata[idx] + } + results.append(result) + + return results + + def get_stats(self) -> Dict[str, Any]: + """获取数据库统计信息""" + return { + 'total_images': self.index.ntotal, + 'dimension': self.dimension, + 'model_name': self.model_name, + 'pretrained': self.pretrained, + 'db_path': self.db_path, + 'device': self.device + } + + def remove_image(self, image_id: int) -> bool: + """ + 删除图片(注意:Faiss不支持直接删除,需要重建索引) + + Args: + image_id: 图片ID + + Returns: + 是否成功删除 + """ + if image_id not in self.metadata: + logger.warning(f"图片ID不存在: {image_id}") + return False + + # 删除元数据 + del self.metadata[image_id] + + # 重建索引(仅包含剩余的图片) + self._rebuild_index() + + logger.info(f"图片已删除: {image_id}") + return True + + def _rebuild_index(self): + """重建索引(用于删除操作后)""" + if not self.metadata: + self.index = faiss.IndexFlatIP(self.dimension) + self._save_database() + return + + # 重新编码所有剩余的图片 + vectors = [] + for image_id, data in self.metadata.items(): + vector = self._encode_image(data['image_path']) + if vector is not None: + vectors.append(vector) + else: + # 如果重新编码失败,从元数据中删除 + del self.metadata[image_id] + + # 重建索引 + self.index = faiss.IndexFlatIP(self.dimension) + if vectors: + vectors_array = np.vstack(vectors) + self.index.add(vectors_array) + + self._save_database() \ No newline at end of file diff --git a/spx-algorithm/resource/vector_db/vector_db_utils.py b/spx-algorithm/resource/vector_db/vector_db_utils.py new file mode 100644 index 000000000..4906c8cd4 --- /dev/null +++ b/spx-algorithm/resource/vector_db/vector_db_utils.py @@ -0,0 +1,213 @@ +import os +import logging +from typing import List, Dict, Any, Optional +from vector_database import VectorDatabase + +logger = logging.getLogger(__name__) + + +class VectorDBManager: + """向量数据库管理工具类""" + + def __init__(self, db_path: str = 'vector_db'): + """ + 初始化管理器 + + Args: + db_path: 数据库路径 + """ + self.db_path = db_path + self.db = VectorDatabase(db_path=db_path) + + def index_directory(self, directory_path: str, + supported_extensions: List[str] = None) -> Dict[str, Any]: + """ + 索引目录下的所有图片 + + Args: + directory_path: 目录路径 + supported_extensions: 支持的文件扩展名 + + Returns: + 索引结果统计 + """ + if supported_extensions is None: + supported_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.svg'] + + if not os.path.exists(directory_path): + logger.error(f"目录不存在: {directory_path}") + return {'success': False, 'error': '目录不存在'} + + # 收集所有图片文件 + image_files = [] + for root, dirs, files in os.walk(directory_path): + for file in files: + _, ext = os.path.splitext(file.lower()) + if ext in supported_extensions: + image_path = os.path.join(root, file) + image_files.append(image_path) + + if not image_files: + logger.warning(f"目录中未找到支持的图片文件: {directory_path}") + return {'success': True, 'indexed': 0, 'failed': 0, 'total': 0} + + logger.info(f"找到 {len(image_files)} 个图片文件,开始索引...") + + # 准备元数据 + metadatas = [] + for image_path in image_files: + metadata = { + 'filename': os.path.basename(image_path), + 'directory': os.path.dirname(image_path), + 'file_size': os.path.getsize(image_path), + 'relative_path': os.path.relpath(image_path, directory_path) + } + metadatas.append(metadata) + + # 批量添加到数据库 + results = self.db.add_images_batch(image_files, metadatas) + + # 统计结果 + indexed = sum(1 for r in results if r is not None) + failed = len(results) - indexed + + stats = { + 'success': True, + 'total': len(image_files), + 'indexed': indexed, + 'failed': failed, + 'directory': directory_path + } + + logger.info(f"索引完成: {stats}") + return stats + + def search_similar_images(self, query_path: str, k: int = 10) -> List[Dict[str, Any]]: + """ + 搜索相似图片 + + Args: + query_path: 查询图片路径 + k: 返回结果数量 + + Returns: + 相似图片列表 + """ + return self.db.search_by_image(query_path, k) + + def search_by_description(self, description: str, k: int = 10) -> List[Dict[str, Any]]: + """ + 通过文本描述搜索图片 + + Args: + description: 文本描述 + k: 返回结果数量 + + Returns: + 匹配的图片列表 + """ + return self.db.search_by_text(description, k) + + def get_database_info(self) -> Dict[str, Any]: + """获取数据库信息""" + return self.db.get_stats() + + def export_metadata(self, output_file: str = None) -> Dict[str, Any]: + """ + 导出数据库元数据 + + Args: + output_file: 输出文件路径(可选) + + Returns: + 元数据字典 + """ + metadata_export = { + 'database_stats': self.db.get_stats(), + 'images': {} + } + + for image_id, data in self.db.metadata.items(): + metadata_export['images'][image_id] = data + + if output_file: + import json + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(metadata_export, f, indent=2, ensure_ascii=False) + logger.info(f"元数据已导出到: {output_file}") + + return metadata_export + + def find_duplicates(self, similarity_threshold: float = 0.95) -> List[List[Dict[str, Any]]]: + """ + 查找重复或相似的图片 + + Args: + similarity_threshold: 相似度阈值 + + Returns: + 重复图片组列表 + """ + duplicates = [] + processed_ids = set() + + for image_id, data in self.db.metadata.items(): + if image_id in processed_ids: + continue + + # 搜索相似图片 + similar_images = self.db.search_by_image(data['image_path'], k=10) + + # 过滤高相似度图片 + duplicate_group = [] + for similar in similar_images: + if similar['similarity'] >= similarity_threshold and similar['id'] not in processed_ids: + duplicate_group.append(similar) + processed_ids.add(similar['id']) + + if len(duplicate_group) > 1: + duplicates.append(duplicate_group) + + logger.info(f"找到 {len(duplicates)} 组重复图片") + return duplicates + + +def create_sample_database(resource_dir: str = 'resource') -> VectorDBManager: + """ + 创建示例数据库 + + Args: + resource_dir: 资源目录路径 + + Returns: + 数据库管理器实例 + """ + db_manager = VectorDBManager(db_path=os.path.join(resource_dir, 'sample_vector_db')) + + # 如果资源目录存在图片,则索引它们 + if os.path.exists(resource_dir): + logger.info(f"索引资源目录: {resource_dir}") + stats = db_manager.index_directory(resource_dir) + logger.info(f"索引统计: {stats}") + + return db_manager + + +if __name__ == "__main__": + # 设置日志 + logging.basicConfig(level=logging.INFO) + + # 创建示例数据库 + manager = create_sample_database() + + # 显示数据库信息 + info = manager.get_database_info() + print(f"数据库信息: {info}") + + # 示例搜索 + if info['total_images'] > 0: + # 文本搜索示例 + results = manager.search_by_description("cute animal", k=3) + print(f"文本搜索结果: {len(results)} 张图片") + for result in results: + print(f" {result['metadata']['filename']}: {result['similarity']:.3f}") \ No newline at end of file