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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user