feat: 建立 SAM2 标注闭环基线

- 打通工作区真实标注闭环:支持手工多边形、矩形、圆形、点区域和线段生成 mask,并可保存、回显、更新和删除后端 annotation。

- 增强 polygon 编辑器:支持顶点拖动、顶点删除、边中点插入、多 polygon 子区域选择编辑,以及区域合并和区域去除。

- 接入 GT mask 导入:后端支持二值/多类别 mask 拆分、contour 转 polygon、distance transform seed point,前端支持导入、回显和 seed point 拖动编辑。

- 完善导出能力:COCO JSON 导出对齐前端,PNG mask ZIP 同时包含单标注 mask、按 zIndex 融合的 semantic_frame 和 semantic_classes.json。

- 打通异步任务管理:新增任务取消、重试、失败详情接口与 Dashboard 控件,worker 支持取消状态检查并通过 Redis/WebSocket 推送 cancelled 事件。

- 对接 Dashboard 后端数据:概览统计、解析队列和实时流转记录从 FastAPI 聚合接口与 WebSocket 更新。

- 增强 AI 推理参数:前端发送 crop_to_prompt、auto_filter_background 和 min_score,后端支持点/框 prompt 局部裁剪推理、结果回映射和负向点/低分过滤。

- 接入 SAM3 基础设施:新增独立 Python 3.12 sam3 环境安装脚本、外部 worker helper、后端桥接和真实 Python/CUDA/包/HF checkpoint access 状态检测。

- 保留 SAM3 授权边界:当前官方 facebook/sam3 gated 权重未授权时状态接口会返回不可用,不伪装成可推理。

- 增强前端状态管理:新增 mask undo/redo 历史栈、AI 模型选择状态、保存状态 dirty/draft/saved 流转和项目状态归一化。

- 更新前端 API 封装:补充 annotation CRUD、GT mask import、mask ZIP export、task cancel/retry/detail、AI runtime status 和 prediction options。

- 更新 UI 控件:ToolsPalette、AISegmentation、VideoWorkspace 和 CanvasArea 接入真实操作、导入导出、撤销重做、任务控制和模型状态。

- 新增 polygon-clipping 依赖,用于前端区域 union/difference 几何运算。

- 完善后端 schemas/status/progress:补充 AI 模型外部状态字段、任务 cancelled 状态和进度事件 payload。

- 补充测试覆盖:新增后端任务控制、SAM3 桥接、GT mask、导出融合、AI options 测试;补充前端 Canvas、Dashboard、VideoWorkspace、ToolsPalette、API 和 store 测试。

- 更新 README、AGENTS 和 doc 文档:冻结当前需求/设计/测试计划,标注真实功能、剩余 Mock、SAM3 授权边界和后续实施顺序。
This commit is contained in:
2026-05-01 15:26:25 +08:00
parent f020ff3b4f
commit 689a9ba283
48 changed files with 3280 additions and 176 deletions

View File

@@ -1,8 +1,17 @@
import React, { useState, useEffect } from 'react';
import { Activity, Clock, Folders, CheckCircle2, Loader2 } from 'lucide-react';
import { Activity, AlertTriangle, Clock, Folders, CheckCircle2, Info, Loader2, RotateCcw, XCircle } from 'lucide-react';
import { progressWS, type ProgressMessage } from '../lib/websocket';
import { cn } from '../lib/utils';
import { getDashboardOverview, type DashboardActivity, type DashboardOverview, type DashboardTask } from '../lib/api';
import {
cancelTask,
getDashboardOverview,
getTask,
retryTask,
type DashboardActivity,
type DashboardOverview,
type DashboardTask,
type ProcessingTask,
} from '../lib/api';
const emptySummary: DashboardOverview['summary'] = {
project_count: 0,
@@ -20,6 +29,29 @@ export function Dashboard() {
const [activityLog, setActivityLog] = useState<DashboardActivity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [selectedTask, setSelectedTask] = useState<ProcessingTask | null>(null);
const [taskActionMessage, setTaskActionMessage] = useState('');
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
const taskFromProcessingTask = (task: ProcessingTask, name = `任务 ${task.id}`): DashboardTask => ({
id: `task-${task.id}`,
task_id: task.id,
project_id: task.project_id ?? 0,
name,
progress: task.progress,
status: task.message || task.status,
raw_status: task.status,
error: task.error,
frame_count: Number(task.result?.frames_extracted || 0),
updated_at: task.updated_at,
});
const prependActivity = (message: string, project = '系统') => {
setActivityLog((prev) => [
{ id: `task-action-${Date.now()}`, kind: 'task', time: new Date().toISOString(), message, project },
...prev.slice(0, 9),
]);
};
useEffect(() => {
let cancelled = false;
@@ -90,6 +122,8 @@ export function Dashboard() {
name: taskTitle(data),
progress: data.progress ?? 0,
status: data.status ?? '处理中',
raw_status: 'running',
error: data.error,
frame_count: 0,
updated_at: new Date().toISOString(),
},
@@ -100,7 +134,7 @@ export function Dashboard() {
if (data.type === 'complete' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId ? { ...t, progress: 100, status: '已完成' } : t
t.id === data.taskId ? { ...t, progress: 100, status: '已完成', raw_status: 'success' } : t
)
);
setActivityLog((prev) => [
@@ -109,10 +143,26 @@ export function Dashboard() {
]);
}
if (data.type === 'cancelled' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId
? { ...t, progress: 100, status: data.message || '任务已取消', raw_status: 'cancelled', error: data.error }
: t
)
);
setActivityLog((prev) => [
{ id: `ws-cancelled-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `任务已取消: ${taskTitle(data)}`, project: data.projectName || '系统' },
...prev.slice(0, 9),
]);
}
if (data.type === 'error' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId ? { ...t, progress: data.progress ?? t.progress, status: `错误: ${data.error || data.message || '未知错误'}` } : t
t.id === data.taskId
? { ...t, progress: data.progress ?? t.progress, status: `错误: ${data.error || data.message || '未知错误'}`, raw_status: 'failed', error: data.error }
: t
)
);
setActivityLog((prev) => [
@@ -160,6 +210,65 @@ export function Dashboard() {
});
}
const taskRawStatus = (task: DashboardTask): string => task.raw_status || (
task.status.includes('取消') ? 'cancelled'
: task.status.includes('失败') || task.status.includes('错误') ? 'failed'
: task.progress >= 100 ? 'success'
: 'running'
);
const canCancel = (task: DashboardTask): boolean => ['queued', 'running'].includes(taskRawStatus(task)) && Boolean(task.task_id);
const canRetry = (task: DashboardTask): boolean => ['failed', 'cancelled'].includes(taskRawStatus(task)) && Boolean(task.task_id);
const handleCancelTask = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
const updated = await cancelTask(task.task_id);
setTasks((prev) => prev.map((item) => (
item.id === task.id ? taskFromProcessingTask(updated, task.name) : item
)));
prependActivity(`任务已取消 #${updated.id}`, task.name);
} catch (err) {
console.error('Cancel task failed:', err);
setTaskActionMessage('任务取消失败,请检查后端服务');
} finally {
setBusyTaskId(null);
}
};
const handleRetryTask = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
const retried = await retryTask(task.task_id);
const dashboardTask = taskFromProcessingTask(retried, task.name);
setTasks((prev) => [dashboardTask, ...prev.filter((item) => item.id !== dashboardTask.id)]);
prependActivity(`重试任务已入队 #${retried.id}`, task.name);
} catch (err) {
console.error('Retry task failed:', err);
setTaskActionMessage('任务重试失败,请检查后端服务');
} finally {
setBusyTaskId(null);
}
};
const handleOpenTaskDetail = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
setSelectedTask(await getTask(task.task_id));
} catch (err) {
console.error('Load task detail failed:', err);
setTaskActionMessage('失败详情加载失败');
} finally {
setBusyTaskId(null);
}
};
return (
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
<header className="mb-8">
@@ -177,6 +286,7 @@ export function Dashboard() {
</div>
<p className="text-gray-400 text-sm mt-1"></p>
{loadError && <p className="text-red-400 text-xs mt-2">{loadError}</p>}
{taskActionMessage && <p className="text-amber-400 text-xs mt-2">{taskActionMessage}</p>}
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
@@ -213,16 +323,47 @@ export function Dashboard() {
<div className="h-full bg-gradient-to-r from-cyan-600 to-cyan-400 rounded-full transition-all duration-500" style={{ width: `${task.progress}%` }} />
</div>
<div className="text-xs text-gray-500 flex items-center gap-2">
{task.status === '已完成' || task.progress >= 100 ? (
{taskRawStatus(task) === 'success' || task.status === '已完成' ? (
<CheckCircle2 size={12} className="text-emerald-400" />
) : task.status.includes('错误') ? (
<span className="text-red-400"></span>
) : taskRawStatus(task) === 'failed' ? (
<AlertTriangle size={12} className="text-red-400" />
) : taskRawStatus(task) === 'cancelled' ? (
<XCircle size={12} className="text-amber-400" />
) : (
<Loader2 size={12} className="text-cyan-400 animate-spin" />
)}
{task.status}
<span className="text-gray-600">: {task.frame_count}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{canCancel(task) && (
<button
onClick={() => handleCancelTask(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-red-500/20 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<XCircle size={12} />
</button>
)}
{canRetry(task) && (
<button
onClick={() => handleRetryTask(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[11px] text-cyan-300 hover:bg-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<RotateCcw size={12} />
</button>
)}
{task.task_id && (
<button
onClick={() => handleOpenTaskDetail(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Info size={12} />
</button>
)}
</div>
</div>
))}
{!isLoading && tasks.length === 0 && (
@@ -253,6 +394,46 @@ export function Dashboard() {
</div>
</div>
</div>
{selectedTask && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-2xl rounded-lg border border-white/10 bg-[#111] p-5 shadow-2xl">
<div className="flex items-center justify-between gap-3 border-b border-white/10 pb-3">
<div>
<h3 className="text-sm font-semibold text-white"> #{selectedTask.id}</h3>
<p className="mt-1 text-xs text-gray-500">{selectedTask.message || selectedTask.status}</p>
</div>
<button
onClick={() => setSelectedTask(null)}
className="rounded border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 hover:bg-white/10"
>
</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-gray-400">
<div>: <span className="text-gray-200">{selectedTask.status}</span></div>
<div>: <span className="text-gray-200">{selectedTask.progress}%</span></div>
<div> ID: <span className="text-gray-200">{selectedTask.project_id ?? '-'}</span></div>
<div>Celery ID: <span className="text-gray-200">{selectedTask.celery_task_id || '-'}</span></div>
<div>: <span className="text-gray-200">{selectedTask.created_at}</span></div>
<div>: <span className="text-gray-200">{selectedTask.finished_at || '-'}</span></div>
</div>
{selectedTask.error && (
<div className="mt-4 rounded border border-red-500/20 bg-red-500/10 p-3 text-xs text-red-200">
{selectedTask.error}
</div>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
<pre className="max-h-48 overflow-auto rounded border border-white/10 bg-[#0a0a0a] p-3 text-[11px] text-gray-300">
{JSON.stringify(selectedTask.payload || {}, null, 2)}
</pre>
<pre className="max-h-48 overflow-auto rounded border border-white/10 bg-[#0a0a0a] p-3 text-[11px] text-gray-300">
{JSON.stringify(selectedTask.result || {}, null, 2)}
</pre>
</div>
</div>
</div>
)}
</div>
);
}