2026-04-29-21-51-19 - 全栈系统改造:FastAPI后端+SAM2+PostgreSQL+Redis+MinIO+前端Zustand重构
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Stage, Layer, Image as KonvaImage, Circle, Rect, Path, Group } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { predictMask } from '../lib/api';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface CanvasAreaProps {
|
||||
activeTool: string;
|
||||
@@ -13,6 +16,17 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [points, setPoints] = useState<{ x: number, y: number, type: 'pos'|'neg' }[]>([]);
|
||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
||||
const [boxStart, setBoxStart] = useState<{ x: number, y: number } | null>(null);
|
||||
const [boxCurrent, setBoxCurrent] = useState<{ x: number, y: number } | null>(null);
|
||||
const [isInferencing, setIsInferencing] = useState(false);
|
||||
|
||||
const masks = useStore((state) => state.masks);
|
||||
const addMask = useStore((state) => state.addMask);
|
||||
const clearMasks = useStore((state) => state.clearMasks);
|
||||
const storeActiveTool = useStore((state) => state.activeTool);
|
||||
const setActiveTool = useStore((state) => state.setActiveTool);
|
||||
|
||||
const effectiveTool = activeTool || storeActiveTool;
|
||||
|
||||
// We load a mock image representing a frame
|
||||
const [image] = useImage('https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop');
|
||||
@@ -56,35 +70,120 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
if (!stage) return;
|
||||
const pos = stage.getPointerPosition();
|
||||
if (pos) {
|
||||
// Convert to image coordinates
|
||||
const imageX = (pos.x - position.x) / scale;
|
||||
const imageY = (pos.y - position.y) / scale;
|
||||
setCursorPos({ x: imageX, y: imageY });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStageClick = (e: any) => {
|
||||
if (activeTool === 'move') return;
|
||||
|
||||
if (activeTool === 'point_pos' || activeTool === 'point_neg') {
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getRelativePointerPosition();
|
||||
setPoints([...points, { x: pos.x, y: pos.y, type: activeTool === 'point_pos' ? 'pos' : 'neg' }]);
|
||||
if (boxStart && effectiveTool === 'box_select') {
|
||||
const relPos = stage.getRelativePointerPosition();
|
||||
if (relPos) {
|
||||
setBoxCurrent({ x: relPos.x, y: relPos.y });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runInference = useCallback(async (promptPoints?: typeof points, promptBox?: { x1: number, y1: number, x2: number, y2: number }) => {
|
||||
setIsInferencing(true);
|
||||
try {
|
||||
const result = await predictMask({
|
||||
imageUrl: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop',
|
||||
points: promptPoints?.map((p) => ({ x: p.x, y: p.y, type: p.type })),
|
||||
box: promptBox,
|
||||
});
|
||||
|
||||
result.masks.forEach((m) => {
|
||||
addMask({
|
||||
id: m.id,
|
||||
frameId: 'frame-1',
|
||||
pathData: m.pathData,
|
||||
label: m.label,
|
||||
color: m.color,
|
||||
segmentation: m.segmentation,
|
||||
bbox: m.bbox,
|
||||
area: m.area,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Inference failed:', err);
|
||||
} finally {
|
||||
setIsInferencing(false);
|
||||
}
|
||||
}, [addMask]);
|
||||
|
||||
const handleStageMouseDown = (e: any) => {
|
||||
if (effectiveTool === 'box_select') {
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getRelativePointerPosition();
|
||||
if (pos) {
|
||||
setBoxStart({ x: pos.x, y: pos.y });
|
||||
setBoxCurrent({ x: pos.x, y: pos.y });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStageMouseUp = (e: any) => {
|
||||
if (effectiveTool === 'box_select' && boxStart && boxCurrent) {
|
||||
const x1 = Math.min(boxStart.x, boxCurrent.x);
|
||||
const y1 = Math.min(boxStart.y, boxCurrent.y);
|
||||
const x2 = Math.max(boxStart.x, boxCurrent.x);
|
||||
const y2 = Math.max(boxStart.y, boxCurrent.y);
|
||||
|
||||
if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) {
|
||||
runInference(undefined, { x1, y1, x2, y2 });
|
||||
}
|
||||
|
||||
setBoxStart(null);
|
||||
setBoxCurrent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStageClick = (e: any) => {
|
||||
if (effectiveTool === 'move') return;
|
||||
if (effectiveTool === 'box_select') return; // handled by mouseup
|
||||
|
||||
if (effectiveTool === 'point_pos' || effectiveTool === 'point_neg') {
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getRelativePointerPosition();
|
||||
if (pos) {
|
||||
const newPoints = [...points, { x: pos.x, y: pos.y, type: effectiveTool === 'point_pos' ? 'pos' : 'neg' as 'pos'|'neg' }];
|
||||
setPoints(newPoints);
|
||||
// Auto-trigger inference after point selection
|
||||
runInference(newPoints);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const boxRect = React.useMemo(() => {
|
||||
if (!boxStart || !boxCurrent) return null;
|
||||
const x = Math.min(boxStart.x, boxCurrent.x);
|
||||
const y = Math.min(boxStart.y, boxCurrent.y);
|
||||
const width = Math.abs(boxCurrent.x - boxStart.x);
|
||||
const height = Math.abs(boxCurrent.y - boxStart.y);
|
||||
return { x, y, width, height };
|
||||
}, [boxStart, boxCurrent]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative cursor-crosshair overflow-hidden rounded-sm">
|
||||
{isInferencing && (
|
||||
<div className="absolute top-4 right-4 z-20 flex items-center gap-2 bg-[#111] border border-white/10 px-3 py-2 rounded-lg shadow-xl">
|
||||
<div className="w-3 h-3 border-2 border-cyan-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-cyan-400 font-mono">AI 推理中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stage
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
onWheel={handleWheel}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleStageMouseDown}
|
||||
onMouseUp={handleStageMouseUp}
|
||||
scaleX={scale}
|
||||
scaleY={scale}
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
draggable={activeTool === 'move'}
|
||||
draggable={effectiveTool === 'move'}
|
||||
onClick={handleStageClick}
|
||||
>
|
||||
<Layer>
|
||||
@@ -98,24 +197,38 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mock Instance Mask overlapping */}
|
||||
<Group opacity={0.4}>
|
||||
<Path
|
||||
data="M 300 200 Q 400 150 450 250 T 400 350 Q 250 350 280 250 Z"
|
||||
fill="#06b6d4" // cyan-500
|
||||
{/* AI Returned Masks */}
|
||||
{masks.map((mask) => (
|
||||
<Group key={mask.id} opacity={0.5}>
|
||||
<Path
|
||||
data={mask.pathData}
|
||||
fill={mask.color}
|
||||
stroke={mask.color}
|
||||
strokeWidth={1 / scale}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{/* Box selection preview */}
|
||||
{boxRect && effectiveTool === 'box_select' && (
|
||||
<Rect
|
||||
x={boxRect.x}
|
||||
y={boxRect.y}
|
||||
width={boxRect.width}
|
||||
height={boxRect.height}
|
||||
fill="rgba(6, 182, 212, 0.1)"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2 / scale}
|
||||
dash={[4 / scale, 4 / scale]}
|
||||
/>
|
||||
<Path
|
||||
data="M 600 400 Q 700 350 750 450 T 650 550 Q 550 550 580 450 Z"
|
||||
fill="#a855f7" // purple-500
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* AI Prompts Point Regions */}
|
||||
{points.map((p, i) => (
|
||||
<Group key={i} x={p.x} y={p.y}>
|
||||
<Circle
|
||||
radius={6 / scale}
|
||||
fill={p.type === 'pos' ? '#22c55e' : '#ef4444'} // green or red
|
||||
fill={p.type === 'pos' ? '#22c55e' : '#ef4444'}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={2 / scale}
|
||||
shadowColor="black"
|
||||
@@ -134,7 +247,17 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
<span>光标: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
|
||||
<span>当前图层树: OBJECT_VEHICLE_01</span>
|
||||
<span>缩放比: {(scale * 100).toFixed(0)}%</span>
|
||||
<span>遮罩数: {masks.length}</span>
|
||||
</div>
|
||||
|
||||
{masks.length > 0 && (
|
||||
<button
|
||||
onClick={clearMasks}
|
||||
className="absolute bottom-4 right-4 text-xs bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
清空遮罩
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user