fix: 改用 URL 方式发送图片给 Qwen-VL,修复数据库查询
This commit is contained in:
parent
914529a9d3
commit
3558eed251
@ -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),
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
573
docs/feature-object-picker.md
Normal file
573
docs/feature-object-picker.md
Normal file
@ -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<PickedObjectResult> {
|
||||
|
||||
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 理解错误"的挫败感
|
||||
- 编辑效率提升:一次点击 > 多次文字描述
|
||||
- 差异化竞争力:独特的交互体验
|
||||
Loading…
x
Reference in New Issue
Block a user