From c74d2b7d44733495d9889f15a86736d8e3a4ceb7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Feb 2026 13:13:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E7=89=A9=E5=93=81?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E4=BA=A4=E4=BA=92=EF=BC=8C=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=9C=A8=E7=94=BB=E5=B8=83=E5=9B=BE=E7=89=87=E4=B8=8A=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E6=97=A0=E9=9C=80=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/canvas/ObjectPickerOverlay.tsx | 5 + components/canvas/nodes/ImageNode.tsx | 310 +++++++++++++++++++++- 2 files changed, 302 insertions(+), 13 deletions(-) diff --git a/components/canvas/ObjectPickerOverlay.tsx b/components/canvas/ObjectPickerOverlay.tsx index 4c2ed63..16441ca 100644 --- a/components/canvas/ObjectPickerOverlay.tsx +++ b/components/canvas/ObjectPickerOverlay.tsx @@ -225,6 +225,11 @@ export function ObjectPickerOverlay() { setPickedMarkers([]); }, [setPickerMode]); + // 🆕 改为直接在 ImageNode 中标注,不再使用全屏覆盖层 + // 保留此组件以便将来可能需要切换回覆盖层模式 + return null; + + // 原覆盖层代码(已禁用) if (!isPickerMode || !imageUrl) return null; return createPortal( diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index ca5cab5..89ec554 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1,8 +1,8 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; -import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair } from "lucide-react"; +import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -29,6 +29,98 @@ import { cn } from "@/lib/utils"; import { AngleEditDialog } from "@/components/angle-edit-dialog"; import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { ImageAnnotator } from "@/components/image-annotator"; +import type { PickedObject } from "@/store/canvas"; + +// 已标注的物品标记(本地状态) +interface PickedMarker { + clickX: number; + clickY: number; + objectName: string; + index: number; +} + +/** + * 在图片上绘制标记点,返回带标记的图片 base64 + */ +async function drawMarkerOnImage( + imageUrl: string, + clickX: number, + clickY: number +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('无法创建 canvas')); + return; + } + + // 压缩图片尺寸(最大 800px) + const maxSize = 800; + let width = img.width; + let height = img.height; + + if (width > maxSize || height > maxSize) { + if (width > height) { + height = (height / width) * maxSize; + width = maxSize; + } else { + width = (width / height) * maxSize; + height = maxSize; + } + } + + canvas.width = width; + canvas.height = height; + ctx.drawImage(img, 0, 0, width, height); + + // 计算标记位置 + const markerX = clickX * width; + const markerY = clickY * height; + const markerRadius = Math.max(12, Math.min(width, height) * 0.025); + + // 绘制红色标记 + ctx.beginPath(); + ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2); + ctx.fillStyle = 'white'; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2); + ctx.fillStyle = '#FF0000'; + ctx.fill(); + + // 绘制十字线 + ctx.strokeStyle = '#FF0000'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(markerX - markerRadius * 2, markerY); + ctx.lineTo(markerX - markerRadius - 2, markerY); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(markerX + markerRadius + 2, markerY); + ctx.lineTo(markerX + markerRadius * 2, markerY); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(markerX, markerY - markerRadius * 2); + ctx.lineTo(markerX, markerY - markerRadius - 2); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(markerX, markerY + markerRadius + 2); + ctx.lineTo(markerX, markerY + markerRadius * 2); + ctx.stroke(); + + resolve(canvas.toDataURL('image/jpeg', 0.85)); + }; + + img.onerror = () => reject(new Error('图片加载失败')); + img.src = imageUrl; + }); +} interface ImageNodeProps { node: CanvasNode; @@ -58,6 +150,113 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const addPendingChatAttachment = useCanvasStore((state) => state.addPendingChatAttachment); const setPickerMode = useCanvasStore((state) => state.setPickerMode); + // 🆕 物品拾取相关状态 + const isPickerMode = useCanvasStore((state) => state.isPickerMode); + const pickerTargetNodeId = useCanvasStore((state) => state.pickerTargetNodeId); + const isPickerLoading = useCanvasStore((state) => state.isPickerLoading); + const setPickerLoading = useCanvasStore((state) => state.setPickerLoading); + const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject); + + // 当前节点是否处于拾取模式 + const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id; + + // 已标注的物品列表(本地状态) + const [pickedMarkers, setPickedMarkers] = useState([]); + // 当前正在识别的点击位置 + const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null); + + // 退出拾取模式时清空标记 + useEffect(() => { + if (!isThisNodePicking) { + setPickedMarkers([]); + setCurrentClick(null); + } + }, [isThisNodePicking]); + + // 🆕 处理图片点击标注 + const handlePickerClick = useCallback(async (e: React.MouseEvent) => { + if (!isThisNodePicking || isPickerLoading || !data.src) return; + + e.stopPropagation(); + e.preventDefault(); + + const img = e.currentTarget; + const rect = img.getBoundingClientRect(); + + // 计算相对坐标 (0-1) + const clickX = (e.clientX - rect.left) / rect.width; + const clickY = (e.clientY - rect.top) / rect.height; + + setCurrentClick({ x: clickX, y: clickY }); + setPickerLoading(true); + + try { + // 在图片上绘制红色标记点 + const markedImageBase64 = await drawMarkerOnImage(data.src, clickX, clickY); + + const response = await fetch('/api/pick-object', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + imageData: markedImageBase64, + clickX, + clickY, + }), + }); + + if (!response.ok) throw new Error('识别失败'); + + const result = await response.json(); + + if (result.success && result.object) { + const picked: PickedObject = { + imageUrl: data.src, + imageNodeId: node.id, + clickX, + clickY, + objectName: result.object.name, + objectPosition: result.object.position, + confidence: result.object.confidence, + boundingBox: result.object.boundingBox, + }; + + addPendingPickedObject(picked); + + setPickedMarkers(prev => [...prev, { + clickX, + clickY, + objectName: result.object.name, + index: prev.length + 1, + }]); + + toast.success(`已添加: ${result.object.name}`); + setCurrentClick(null); + } else { + toast.error(result.error || '未能识别物品'); + setCurrentClick(null); + } + } catch (error) { + console.error('物品拾取失败:', error); + toast.error('识别失败,请重试'); + setCurrentClick(null); + } finally { + setPickerLoading(false); + } + }, [isThisNodePicking, isPickerLoading, data.src, node.id, setPickerLoading, addPendingPickedObject]); + + // 🆕 完成标注 + const handleFinishPicking = useCallback(() => { + setPickerMode(false); + if (pickedMarkers.length > 0) { + toast.success(`已完成 ${pickedMarkers.length} 个物品标注`); + } + }, [setPickerMode, pickedMarkers.length]); + + // 🆕 取消标注 + const handleCancelPicking = useCallback(() => { + setPickerMode(false); + }, [setPickerMode]); + // 获取工具栏位置 - 在渲染时直接计算 const getToolbarPosition = () => { if (containerRef.current) { @@ -418,18 +617,103 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - {/* 图片内容 - 使用 object-contain 保持原始比例,双击可放大查看 */} + {/* 图片内容 - 使用 object-contain 保持原始比例 */} {data.src ? ( - {data.prompt { - e.stopPropagation(); - setFullscreenPreviewOpen(true); - }} - /> +
+ {data.prompt { + if (!isThisNodePicking) { + e.stopPropagation(); + setFullscreenPreviewOpen(true); + } + }} + /> + + {/* 🆕 拾取模式:显示已标注的物品标记 */} + {isThisNodePicking && pickedMarkers.map((marker) => ( +
+ {/* 序号标记 */} +
+
+
+ {marker.index} +
+
+ {/* 物品名称 */} +
+ {marker.objectName} +
+
+ ))} + + {/* 🆕 拾取模式:当前点击位置(正在识别) */} + {isThisNodePicking && currentClick && ( +
+
+
+ )} + + {/* 🆕 拾取模式:状态栏 */} + {isThisNodePicking && ( +
+ + + {isPickerLoading + ? '正在识别...' + : pickedMarkers.length > 0 + ? `已标注 ${pickedMarkers.length} 个` + : '点击标注物品'} + + {pickedMarkers.length > 0 && ( + + )} + +
+ )} +
) : data.status === 'generating' ? (