From 3558eed2510d9f9192779588aba9f23768a29766 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Feb 2026 12:05:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B9=E7=94=A8=20URL=20=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E5=8F=91=E9=80=81=E5=9B=BE=E7=89=87=E7=BB=99=20Qwen-V?= =?UTF-8?q?L=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/pick-object/route.ts | 42 +- components/canvas/ObjectPickerOverlay.tsx | 61 +-- docs/feature-object-picker.md | 573 ++++++++++++++++++++++ 3 files changed, 599 insertions(+), 77 deletions(-) create mode 100644 docs/feature-object-picker.md diff --git a/app/api/pick-object/route.ts b/app/api/pick-object/route.ts index f3cc8a3..03fab8d 100644 --- a/app/api/pick-object/route.ts +++ b/app/api/pick-object/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { aiProvider } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; export const runtime = "nodejs"; export const maxDuration = 30; @@ -14,10 +13,9 @@ const POSITION_OPTIONS = [ ] as const; interface PickObjectRequest { - imageData: string; // Base64 压缩后的图片 + imageUrl: string; // 图片 URL(不再使用 Base64) clickX: number; // 点击位置 X (0-1) clickY: number; // 点击位置 Y (0-1) - originalImageUrl: string; // 原图 URL } interface PickObjectResult { @@ -35,13 +33,15 @@ interface PickObjectResult { export async function POST(request: Request) { try { const body: PickObjectRequest = await request.json(); - const { imageData, clickX, clickY } = body; + const { imageUrl, clickX, clickY } = body; + + console.log('[PickObject] 收到请求:', { imageUrl: imageUrl?.substring(0, 50), clickX, clickY }); // 验证参数 - if (!imageData) { + if (!imageUrl) { return NextResponse.json({ success: false, - error: '缺少图片数据', + error: '缺少图片 URL', }, { status: 400 }); } @@ -52,17 +52,17 @@ export async function POST(request: Request) { }, { status: 400 }); } - // 获取 Qwen-VL 配置 - const [qwenProvider] = await db - .select() - .from(aiProvider) - .where(eq(aiProvider.providerId, 'qwen')) - .limit(1); + // 获取 Qwen-VL 配置 - 使用原生 SQL 查询 + const providers = await db.select().from(aiProvider); + const qwenProvider = providers.find(p => p.providerId === 'qwen'); + + console.log('[PickObject] 找到 providers:', providers.length, '个'); + console.log('[PickObject] Qwen provider:', qwenProvider ? '已找到' : '未找到'); if (!qwenProvider || !qwenProvider.isEnabled || !qwenProvider.apiKey) { return NextResponse.json({ success: false, - error: 'Qwen 服务未配置', + error: 'Qwen 服务未配置或未启用', }, { status: 500 }); } @@ -89,8 +89,11 @@ ${POSITION_OPTIONS.join('、')} 【boundingBox】相对坐标 0-1,精确到小数点后2位 `.trim(); - // 调用 Qwen-VL API - const response = await fetch(`${qwenProvider.apiBaseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1'}/chat/completions`, { + const apiUrl = `${qwenProvider.apiBaseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1'}/chat/completions`; + console.log('[PickObject] 调用 Qwen API:', apiUrl); + + // 调用 Qwen-VL API(使用图片 URL) + const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -105,7 +108,7 @@ ${POSITION_OPTIONS.join('、')} { type: 'image_url', image_url: { - url: imageData, // Base64 格式的图片 + url: imageUrl, // 直接使用图片 URL }, }, { @@ -119,18 +122,22 @@ ${POSITION_OPTIONS.join('、')} }), }); + console.log('[PickObject] Qwen API 响应状态:', response.status); + if (!response.ok) { const errorText = await response.text(); console.error('[PickObject] Qwen API error:', errorText); return NextResponse.json({ success: false, - error: 'AI 识别服务错误', + error: `AI 识别服务错误: ${response.status}`, }, { status: 500 }); } const data = await response.json(); const content = data.choices?.[0]?.message?.content; + console.log('[PickObject] Qwen 返回内容:', content?.substring(0, 200)); + if (!content) { return NextResponse.json({ success: false, @@ -169,7 +176,6 @@ ${POSITION_OPTIONS.join('、')} if (!result.boundingBox || typeof result.boundingBox.x !== 'number' || typeof result.boundingBox.y !== 'number') { - // 根据点击位置生成默认边界框 result.boundingBox = { x: Math.max(0, clickX - 0.15), y: Math.max(0, clickY - 0.15), diff --git a/components/canvas/ObjectPickerOverlay.tsx b/components/canvas/ObjectPickerOverlay.tsx index 91c2488..fda5843 100644 --- a/components/canvas/ObjectPickerOverlay.tsx +++ b/components/canvas/ObjectPickerOverlay.tsx @@ -8,59 +8,6 @@ import { useCanvasStore, type PickedObject } from "@/store/canvas"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -// 图片压缩配置 -const PICKER_IMAGE_CONFIG = { - maxSize: 600, - quality: 0.85, -}; - -// 压缩图片 -async function compressImageForPicker(imageUrl: string): Promise<{ - compressedDataUrl: string; - scale: number; - originalWidth: number; - originalHeight: number; -}> { - const img = new Image(); - img.crossOrigin = 'anonymous'; - - return new Promise((resolve, reject) => { - img.onload = () => { - const { naturalWidth, naturalHeight } = img; - let scale = 1; - let targetWidth = naturalWidth; - let targetHeight = naturalHeight; - - // 计算缩放比例 - if (naturalWidth > PICKER_IMAGE_CONFIG.maxSize || naturalHeight > PICKER_IMAGE_CONFIG.maxSize) { - scale = Math.min( - PICKER_IMAGE_CONFIG.maxSize / naturalWidth, - PICKER_IMAGE_CONFIG.maxSize / naturalHeight - ); - targetWidth = Math.round(naturalWidth * scale); - targetHeight = Math.round(naturalHeight * scale); - } - - // Canvas 压缩 - const canvas = document.createElement('canvas'); - canvas.width = targetWidth; - canvas.height = targetHeight; - const ctx = canvas.getContext('2d')!; - ctx.drawImage(img, 0, 0, targetWidth, targetHeight); - - const compressedDataUrl = canvas.toDataURL('image/jpeg', PICKER_IMAGE_CONFIG.quality); - - resolve({ - compressedDataUrl, - scale, - originalWidth: naturalWidth, - originalHeight: naturalHeight, - }); - }; - img.onerror = () => reject(new Error('图片加载失败')); - img.src = imageUrl; - }); -} export function ObjectPickerOverlay() { const isPickerMode = useCanvasStore((state) => state.isPickerMode); @@ -96,18 +43,14 @@ export function ObjectPickerOverlay() { setPickerLoading(true); try { - // 压缩图片 - const { compressedDataUrl } = await compressImageForPicker(imageUrl); - - // 调用 API + // 直接使用图片 URL 调用 API(不再压缩为 Base64) const response = await fetch('/api/pick-object', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - imageData: compressedDataUrl, + imageUrl, // 直接发送图片 URL clickX, clickY, - originalImageUrl: imageUrl, }), }); diff --git a/docs/feature-object-picker.md b/docs/feature-object-picker.md new file mode 100644 index 0000000..3699ca3 --- /dev/null +++ b/docs/feature-object-picker.md @@ -0,0 +1,573 @@ +# 「物品拾取器」功能开发方案 v2.0 + +> 📅 更新日期:2024-02 +> 🔍 基于行业调研和技术分析的优化方案 + +--- + +## 一、需求分析 + +### 1.1 用户原始需求 +- 用户选中画布图片后,点击"获取物品"按钮 +- 鼠标变成红色小手图标 +- 点击图片中的物品,小手图标贴在点击位置 +- 后台调用 QWEN-VL 模型识别物品名称和位置 +- 生成包含物品信息的标签到对话框 + +### 1.2 深度理解:用户的真实目标 + +**核心痛点:** 用户想要精准地告诉 AI "我要处理图片中的这个东西" + +**当前问题:** +1. 用户只能用文字描述物品(如"左边的椅子"),AI 可能理解错误 +2. 复杂场景中,文字描述不够精准(如"第二排右边第三个人") +3. 用户不知道 AI 能否正确识别他想要的物品 + +**用户期望:** +- 像"指哪打哪"一样精准指定目标物品 +- AI 能准确理解并反馈识别结果 +- 后续编辑操作(替换、删除、修改)能精准作用于该物品 + +--- + +## 二、行业调研与技术选型 + +### 2.1 竞品功能分析 + +| 产品 | 功能名称 | 实现方式 | 优缺点 | +|------|----------|----------|--------| +| **Midjourney** | Vary Region | 矩形/套索工具手动选区,修改 prompt 重新生成 | ✅ 灵活 ❌ 需手动勾选,学习成本高 | +| **Adobe Firefly** | Generative Fill | 点击+自动识别边界,AI 填充 | ✅ 智能边界识别 ❌ 依赖 Adobe 生态 | +| **Canva Magic Edit** | Magic Eraser | 画笔涂抹选区 | ✅ 直观 ❌ 精度依赖用户手绘 | +| **Remove.bg** | 前景分离 | 自动识别主体 | ✅ 一键操作 ❌ 只能选主体 | + +### 2.2 核心技术调研 + +#### SAM (Segment Anything Model) - Meta +- **能力**:点击即可分割任意物品,生成精准蒙版 +- **优势**: + - 支持点击 (Point)、框选 (Box)、文本 (Text) 三种选择方式 + - 可在浏览器端运行(WebAssembly + ONNX) + - 实时响应,无需等待 API +- **API 服务**: + - Segmind API:$0.008/次,~3.8s 响应 + - Fal.ai SAM2:支持 WebSocket 实时连接 + +#### Qwen-VL 视觉语言模型 +- **能力**:识别物品名称、位置、属性 +- **输出格式**: + - `bbox_2d`:边界框坐标 + - `point_2d`:点坐标 + - 物品描述文本 +- **局限**:复杂场景准确率约 45%(需要结合分割模型提升精度) + +#### Google MediaPipe Interactive Segmenter +- **能力**:点击生成分割蒙版 +- **平台**:Android、Web、Python +- **优势**:轻量级,适合移动端 + +### 2.3 技术方案对比 + +| 方案 | 描述 | 延迟 | 精度 | 实现成本 | +|------|------|------|------|----------| +| **A. 纯 VLM** | 点击坐标 → Qwen-VL → 名称+位置 | ~2s | ⭐⭐ | 低 | +| **B. SAM + VLM** | 点击 → SAM 分割 → VLM 识别 | ~4s | ⭐⭐⭐⭐ | 中 | +| **C. 浏览器 SAM** | 预计算 Embedding → 点击即时分割 | <100ms | ⭐⭐⭐⭐⭐ | 高 | +| **D. SAM API + VLM** | SAM Cloud API → VLM | ~5s | ⭐⭐⭐⭐ | 中 | + +### 2.4 推荐方案 + +**MVP 阶段**:方案 A(纯 VLM) +- 快速上线验证需求 +- 1-2 天开发周期 + +**正式版本**:方案 B/D(SAM + VLM) +- 提供物品轮廓可视化 +- 蒙版数据可用于后续精准编辑 + +**高级版本**:方案 C(浏览器端 SAM) +- 极致体验,鼠标悬停即可预览轮廓 +- 适合高频用户场景 + +--- + +## 三、功能设计 + +### 3.1 功能定位 + +**「物品拾取器」** - 可视化的物品选择工具,让用户通过点击精准指定图片中的目标物品。 + +### 3.2 核心价值 + +| 价值点 | 说明 | +|--------|------| +| **精准指定** | 点击即选中,无需文字描述 | +| **即时反馈** | AI 识别后显示物品名称和轮廓,用户可确认 | +| **上下文传递** | 物品信息自动带入对话,后续指令更精准 | +| **降低沟通成本** | 减少"不是这个,是那个"的来回沟通 | + +### 3.3 交互流程(MVP 版本) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 1. 用户选中图片 → 工具栏显示「🎯」拾取按钮 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 点击「🎯」→ 进入拾取模式 │ +│ - 鼠标变成 ⊕ 准星图标 │ +│ - 图片边框变成橙色虚线 │ +│ - 顶部提示:「点击图片中的物品」 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 用户点击图片某位置 │ +│ - 点击位置显示脉冲动画圆点 ◉ │ +│ - 提示:「正在识别...」 │ +│ - 调用 API 识别物品 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 4. 识别完成 → 显示结果浮层 │ +│ │ +│ ┌────────────────────────────────┐ │ +│ │ 🎯 识别结果 │ │ +│ │ │ │ +│ │ ┌──────┐ 黑色皮质沙发 │ │ +│ │ │ 缩略 │ 位置:左侧中央 │ │ +│ │ └──────┘ │ │ +│ │ │ │ +│ │ [重新选择] [添加到对话 ✓] │ │ +│ └────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 5. 用户确认 → 生成标签到对话框 │ +│ │ +│ 对话框: │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 🎯 [缩略图] 黑色皮质沙发 · 左侧中央 │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ 用户输入:「把这个换成白色」 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.4 交互流程(增强版本 - 带 SAM 分割) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 3. 用户点击图片某位置 │ +│ - Step 1: SAM 分割 → 获取物品蒙版 │ +│ - Step 2: VLM 识别 → 获取物品名称 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 4. 识别完成 → 图片上显示物品轮廓 │ +│ │ +│ ┌────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ╭───────────╮ ← 橙色轮廓线 │ │ +│ │ │ 沙发 │ │ │ +│ │ ╰───────────╯ │ │ +│ │ │ │ +│ │ ┌────────────────────────┐ │ │ +│ │ │ 🎯 黑色皮质沙发 │ │ │ +│ │ │ 左侧中央 │ │ │ +│ │ │ [重新选] [添加到对话] │ │ │ +│ │ └────────────────────────┘ │ │ +│ └────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.5 数据结构 + +```typescript +interface PickedObject { + // 原图信息 + imageUrl: string; + imageWidth: number; + imageHeight: number; + + // 点击位置(相对坐标 0-1) + clickX: number; + clickY: number; + + // AI 识别结果(严格格式) + objectName: string; // ≤8字,格式:[颜色/材质]+[物品],如"黑色沙发" + objectPosition: string; // 固定4字,如"左上区域"、"中央区域" + confidence: number; // 置信度 0-1 + + // 物品边界框(必需) + boundingBox: { + x: number; // 相对坐标 0-1,精确到小数点后2位 + y: number; + width: number; + height: number; + }; + + // 分割蒙版(增强版,可选) + mask?: { + dataUrl: string; // Base64 PNG 蒙版图 + polygon?: Array<{ x: number; y: number }>; // 轮廓点 + }; +} + +// 位置词库常量 +const POSITION_OPTIONS = [ + '左上区域', '中央顶部', '右上区域', + '左侧中部', '中央区域', '右侧中部', + '左下区域', '中央底部', '右下区域' +] as const; +``` + +--- + +## 四、技术实现 + +### 4.1 图片压缩优化 + +为加速 AI 分析返回速度,前端在发送图片前需进行压缩: + +```typescript +// 图片压缩配置 +const PICKER_IMAGE_CONFIG = { + maxSize: 600, // 最大宽度或高度 600px + quality: 0.85, // JPEG 质量 + format: 'jpeg' // 输出格式 +}; +``` + +**压缩策略:** +- 宽度 > 600px 或 高度 > 600px 时,等比缩放至最大边 600px +- 例如:1200x800 → 600x400,800x1200 → 400x600 +- 保持宽高比不变,确保点击坐标映射准确 + +**压缩函数:** +```typescript +async function compressImageForPicker(imageUrl: string): Promise<{ + compressedUrl: string; // Base64 或临时 URL + scale: number; // 缩放比例,用于坐标映射 + originalWidth: number; + originalHeight: number; +}> { + const img = new Image(); + img.crossOrigin = 'anonymous'; + + return new Promise((resolve, reject) => { + img.onload = () => { + const { naturalWidth, naturalHeight } = img; + let scale = 1; + let targetWidth = naturalWidth; + let targetHeight = naturalHeight; + + // 计算缩放比例 + if (naturalWidth > 600 || naturalHeight > 600) { + scale = Math.min(600 / naturalWidth, 600 / naturalHeight); + targetWidth = Math.round(naturalWidth * scale); + targetHeight = Math.round(naturalHeight * scale); + } + + // Canvas 压缩 + const canvas = document.createElement('canvas'); + canvas.width = targetWidth; + canvas.height = targetHeight; + const ctx = canvas.getContext('2d')!; + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + + const compressedUrl = canvas.toDataURL('image/jpeg', 0.85); + + resolve({ + compressedUrl, + scale, + originalWidth: naturalWidth, + originalHeight: naturalHeight + }); + }; + img.onerror = reject; + img.src = imageUrl; + }); +} +``` + +### 4.2 API 设计 + +#### 端点 +``` +POST /api/pick-object +``` + +#### 请求 +```json +{ + "imageData": "data:image/jpeg;base64,...", // 压缩后的图片(Base64) + "clickX": 0.35, + "clickY": 0.42, + "originalImageUrl": "https://s3.xxx/image.jpg", // 原图 URL(用于生成标签) + "options": { + "includeMask": false, + "includePolygon": false + } +} +``` + +#### 响应 +```json +{ + "success": true, + "object": { + "name": "黑色皮质沙发", + "position": "图片左侧中央偏下", + "confidence": 0.92, + "boundingBox": { + "x": 0.1, + "y": 0.3, + "width": 0.4, + "height": 0.35 + }, + "mask": null, + "polygon": null + } +} +``` + +### 4.3 后端实现(MVP 版本) + +#### Prompt 设计原则 +- **字数限制**:名称 ≤8字,位置 ≤4字 +- **格式统一**:固定词库,避免自由发挥 +- **简洁清晰**:无冗余修饰词 + +#### 标准词库 + +**物品名称格式**:`[颜色/材质] + [物品]`(≤8字) +``` +✅ 正确:黑色沙发、白色花瓶、木质书架、红色跑车 +❌ 错误:一个非常漂亮的黑色皮质沙发、看起来像是白色的陶瓷花瓶 +``` + +**位置描述词库**(固定4个字): +| 横向 | 纵向 | 组合示例 | +|------|------|----------| +| 左侧 | 顶部 | 左上区域 | +| 中央 | 中部 | 中央区域 | +| 右侧 | 底部 | 右下区域 | + +``` +✅ 正确:左上区域、中央区域、右下区域、左侧中部 +❌ 错误:图片的左边偏上一点的位置、大概在中间靠右 +``` + +#### 实现代码 + +```typescript +// lib/ai/pick-object.ts + +export async function pickObject( + imageData: string, + clickX: number, + clickY: number, + originalImageUrl: string +): Promise { + + const prompt = ` +识别图片中坐标 (${(clickX * 100).toFixed(0)}%, ${(clickY * 100).toFixed(0)}%) 位置的物品。 + +【输出要求 - 严格遵守】 +返回纯 JSON,无其他文字: +{ + "name": "物品名称", + "position": "位置描述", + "confidence": 0.95, + "boundingBox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } +} + +【name 格式】≤8字,格式:[颜色/材质]+[物品] +正确:黑色沙发、白色花瓶、木质书架、玻璃茶几 +错误:一个黑色的皮质沙发(太长) + +【position 格式】固定4字,从以下选择: +横向:左侧/中央/右侧 +纵向:顶部/中部/底部 +组合:左上区域、左侧中部、左下区域、中央顶部、中央区域、中央底部、右上区域、右侧中部、右下区域 + +【boundingBox】相对坐标 0-1,精确到小数点后2位 +`; + + const result = await callQwenVL(imageData, prompt); + return JSON.parse(result); +} +``` + +**性能预估:** +| 图片尺寸 | 压缩后 | API 响应时间 | +|----------|--------|--------------| +| 2000x1500 | 600x450 | ~1-2s | +| 1200x800 | 600x400 | ~1-2s | +| 800x600 | 600x450 | ~1s | +| 600x400 | 不压缩 | <1s | + +### 4.4 前端组件 + +``` +components/ +├── object-picker/ +│ ├── ObjectPickerMode.tsx # 拾取模式状态管理 +│ ├── ObjectPickerOverlay.tsx # 图片遮罩层 +│ ├── ObjectPickerCursor.tsx # 自定义光标 +│ ├── ObjectPickerResult.tsx # 识别结果弹窗 +│ ├── ObjectPickerOutline.tsx # 物品轮廓显示(增强版) +│ └── PickedObjectTag.tsx # 对话框中的物品标签 +``` + +### 4.5 Canvas Store 扩展 + +```typescript +interface CanvasStore { + // ... 现有状态 + + // 物品拾取模式 + isPickerMode: boolean; + pickerTargetNodeId: string | null; + setPickerMode: (enabled: boolean, nodeId?: string) => void; + + // 拾取结果 + pickedObject: PickedObject | null; + setPickedObject: (obj: PickedObject | null) => void; + + // 待添加到对话的拾取物品 + pendingPickedObjects: PickedObject[]; + addPendingPickedObject: (obj: PickedObject) => void; + clearPendingPickedObjects: () => void; +} +``` + +--- + +## 五、对话框集成 + +### 5.1 物品标签样式 + +``` +┌────────────────────────────────┐ +│ 🎯 [缩略图] 黑色沙发 · 左上区域 │ +└────────────────────────────────┘ +``` + +**标签格式**:`🎯 [缩略图] {名称} · {位置}` +- **图标** 🎯:区分普通图片和拾取物品 +- **缩略图**:裁剪自边界框区域 +- **名称**:≤8字,如"黑色沙发" +- **位置**:固定4字,如"左上区域" + +**示例**: +| 完整标签 | 字数 | +|----------|------| +| 🎯 黑色沙发 · 左上区域 | 12字 | +| 🎯 白色花瓶 · 中央区域 | 12字 | +| 🎯 木质书架 · 右下区域 | 12字 | + +### 5.2 发送消息时的数据 + +```typescript +{ + type: "file", + mediaType: "image/jpeg", + url: "https://...", + + // 🆕 物品拾取信息 + pickedObject: { + name: "黑色皮质沙发", + position: "左侧中央", + clickX: 0.35, + clickY: 0.42, + boundingBox: { x: 0.1, y: 0.3, width: 0.4, height: 0.35 } + } +} +``` + +### 5.3 后端 Prompt 增强 + +当消息包含 `pickedObject` 时,自动增强 prompt: + +``` +用户想要处理图片中的「黑色皮质沙发」。 +- 物品位置:左侧中央 +- 边界框:(10%, 30%) - (50%, 65%) +- 用户点击坐标:(35%, 42%) + +用户指令:把这个沙发换成白色 + +请精准地只修改该物品区域,保持其他部分不变。 +``` + +--- + +## 六、扩展功能(未来迭代) + +### 6.1 多物品选择 +- 支持连续点击选择多个物品 +- 对话框显示多个物品标签 +- "把 A 和 B 都删掉" + +### 6.2 负向选择 +- 标记"不要这个" +- 用于排除干扰物品 + +### 6.3 智能建议 +- 进入拾取模式时,自动高亮主要物品 +- 显示可点击的热区提示 + +### 6.4 历史记录 +- 记住用户最近拾取的物品 +- 快速重新选择 + +### 6.5 快捷键 +- 按住 Alt + 点击:直接拾取(跳过确认) +- Esc:退出拾取模式 + +--- + +## 七、开发计划 + +### Phase 1:MVP(1-2 天) +- [ ] 工具栏「🎯」拾取按钮 +- [ ] 拾取模式 UI(光标、提示) +- [ ] `/api/pick-object` API +- [ ] 识别结果弹窗 +- [ ] 物品标签生成到对话框 + +### Phase 2:体验优化(2-3 天) +- [ ] 点击脉冲动画 +- [ ] 边界框可视化显示 +- [ ] 标签样式优化 +- [ ] 错误处理与重试 +- [ ] 支持 ESC 退出 + +### Phase 3:功能增强(3-5 天) +- [ ] SAM 集成,显示物品轮廓 +- [ ] 多物品选择 +- [ ] 拾取历史记录 +- [ ] 快捷键支持 + +--- + +## 八、总结 + +### 功能本质 +**让用户从"描述物品"变成"指向物品"**,大幅降低人机沟通成本,提升图片编辑精准度。 + +### 核心创新 +1. **点击即选中** - 最直观的物品选择方式 +2. **AI 即时反馈** - 显示物品名称和轮廓,用户可确认 +3. **上下文自动传递** - 物品信息(含坐标)无缝带入对话 + +### 竞争优势 +- 市面上少有的"精准物品选择 + AI 编辑"组合 +- 比 Midjourney 更简单(无需手动勾选) +- 比文字描述更准确(减少误解) + +### 预期效果 +- 用户满意度提升:减少"AI 理解错误"的挫败感 +- 编辑效率提升:一次点击 > 多次文字描述 +- 差异化竞争力:独特的交互体验