feat: 打通全栈标注闭环、异步拆帧与模型状态
后端能力: - 新增 Celery app、worker task、ProcessingTask 模型、/api/tasks 查询接口和 media_task_runner,将 /api/media/parse 改为创建后台任务并由 worker 执行 FFmpeg/OpenCV/pydicom 拆帧。 - 新增 Redis 进度事件模块和 FastAPI Redis pub/sub 订阅,将 worker 任务进度广播到 /ws/progress;Dashboard 后端概览接口改为聚合 projects/frames/annotations/templates/processing_tasks。 - 统一项目状态为 pending/parsing/ready/error,新增共享 status 常量,并让前端兼容归一化旧状态值。 - 扩展 AI 后端:新增 SAM registry、SAM2 真实运行状态、SAM3 状态检测与文本语义推理适配入口,以及 /api/ai/models/status GPU/模型状态接口。 - 补齐标注保存/更新/删除、COCO/PNG mask 导出相关后端契约和模板 mapping_rules 打包/解包行为。 前端能力: - 新增运行时 API/WS 地址推导配置,前端 API 封装对齐 FastAPI 路由、字段映射、任务轮询、标注归档、导出下载和 AI 预测响应转换。 - Dashboard 改为读取 /api/dashboard/overview,并订阅 WebSocket progress/complete/error/status 更新解析队列和实时流转记录。 - 项目库导入视频/DICOM 后创建项目、上传媒体、触发异步解析并刷新真实项目列表。 - 工作区加载真实帧、无帧时触发解析任务、回显已保存标注、保存未归档 mask、更新 dirty mask、清空当前帧后端标注、导出 COCO JSON。 - Canvas 支持当前帧点/框提示调用后端 AI、渲染推理/已保存 mask、应用模板分类并维护保存状态计数;时间轴按项目 fps 播放。 - AI 页面新增 SAM2/SAM3 模型选择,预测请求携带 model;侧边栏和工作区新增真实 GPU/SAM 状态徽标。 - 模板库和本体面板接入真实模板 CRUD、分类编辑、拖拽排序、JSON 导入、默认腹腔镜分类和本地自定义分类选择。 测试与文档: - 新增 Vitest 配置、前端测试 setup、API/config/websocket/store/组件测试,覆盖登录、项目库、Dashboard、Canvas、工作区、模型状态、时间轴、本体和模板库。 - 新增 pytest 后端测试夹具和 auth/projects/templates/media/AI/export/dashboard/tasks/progress 测试,使用 SQLite、fake MinIO、fake SAM registry 和 Redis monkeypatch 隔离外部服务。 - 新增 doc/ 文档结构,冻结当前需求、设计、接口契约、测试计划、前端逐元素审计、实现地图和后续实施计划,并同步更新 README 与 AGENTS。 验证: - conda run -n seg_server pytest backend/tests:27 passed。 - npm run test:run:54 passed。 - npm run lint、npm run build、compileall、git diff --check 均通过;Vite 仅提示大 chunk 警告。
This commit is contained in:
@@ -1,10 +1,28 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getProjectFrames, parseMedia, getTemplates } from '../lib/api';
|
||||
import {
|
||||
annotationToMask,
|
||||
buildAnnotationPayload,
|
||||
deleteAnnotation,
|
||||
exportCoco,
|
||||
getProjectAnnotations,
|
||||
getProjectFrames,
|
||||
getTask,
|
||||
getTemplates,
|
||||
parseMedia,
|
||||
saveAnnotation,
|
||||
updateAnnotation,
|
||||
} from '../lib/api';
|
||||
import { CanvasArea } from './CanvasArea';
|
||||
import { ToolsPalette } from './ToolsPalette';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
import { FrameTimeline } from './FrameTimeline';
|
||||
import { ModelStatusBadge } from './ModelStatusBadge';
|
||||
import type { Frame } from '../store/useStore';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) {
|
||||
const activeTool = useStore((state) => state.activeTool);
|
||||
@@ -12,8 +30,26 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const currentProject = useStore((state) => state.currentProject);
|
||||
const frames = useStore((state) => state.frames);
|
||||
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
||||
const masks = useStore((state) => state.masks);
|
||||
const activeTemplateId = useStore((state) => state.activeTemplateId);
|
||||
const setFrames = useStore((state) => state.setFrames);
|
||||
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
|
||||
const setMasks = useStore((state) => state.setMasks);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
|
||||
const hydrateSavedAnnotations = useCallback(async (projectId: string, projectFrames: Frame[]) => {
|
||||
const frameById = new Map(projectFrames.map((frame) => [frame.id, frame]));
|
||||
const annotations = await getProjectAnnotations(projectId);
|
||||
const savedMasks = annotations
|
||||
.map((annotation) => {
|
||||
const frame = annotation.frame_id ? frameById.get(String(annotation.frame_id)) : null;
|
||||
return frame ? annotationToMask(annotation, frame) : null;
|
||||
})
|
||||
.filter((mask): mask is NonNullable<typeof mask> => Boolean(mask));
|
||||
setMasks(savedMasks);
|
||||
}, [setMasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProject?.id) return;
|
||||
@@ -25,34 +61,58 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
if (cancelled) return;
|
||||
|
||||
if (data.length === 0 && currentProject.video_path) {
|
||||
// No frames yet but video exists → trigger parsing
|
||||
// No frames yet but video exists -> queue parsing and poll the task.
|
||||
try {
|
||||
await parseMedia(String(currentProject.id));
|
||||
const task = await parseMedia(String(currentProject.id));
|
||||
if (cancelled) return;
|
||||
setStatusMessage(`解析任务已入队 #${task.id}`);
|
||||
let completed = false;
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const freshTask = await getTask(task.id);
|
||||
if (cancelled) return;
|
||||
setStatusMessage(freshTask.message || `解析进度 ${freshTask.progress}%`);
|
||||
if (freshTask.status === 'success') {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
if (freshTask.status === 'failed') {
|
||||
setStatusMessage(freshTask.error || '解析任务失败');
|
||||
return;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
if (!completed) {
|
||||
setStatusMessage('解析仍在后台运行,可稍后刷新工作区');
|
||||
return;
|
||||
}
|
||||
const fresh = await getProjectFrames(String(currentProject.id));
|
||||
if (cancelled) return;
|
||||
setFrames(fresh.map((f) => ({
|
||||
const mappedFrames = fresh.map((f) => ({
|
||||
id: String(f.id),
|
||||
projectId: String(f.project_id),
|
||||
index: f.frame_index,
|
||||
url: f.image_url,
|
||||
width: f.width ?? 0,
|
||||
height: f.height ?? 0,
|
||||
})));
|
||||
}));
|
||||
setFrames(mappedFrames);
|
||||
setCurrentFrame(0);
|
||||
await hydrateSavedAnnotations(String(currentProject.id), mappedFrames);
|
||||
} catch (err) {
|
||||
console.error('Parse failed:', err);
|
||||
}
|
||||
} else {
|
||||
setFrames(data.map((f) => ({
|
||||
const mappedFrames = data.map((f) => ({
|
||||
id: String(f.id),
|
||||
projectId: String(f.project_id),
|
||||
index: f.frame_index,
|
||||
url: f.image_url,
|
||||
width: f.width ?? 0,
|
||||
height: f.height ?? 0,
|
||||
})));
|
||||
}));
|
||||
setFrames(mappedFrames);
|
||||
setCurrentFrame(0);
|
||||
await hydrateSavedAnnotations(String(currentProject.id), mappedFrames);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load frames:', err);
|
||||
@@ -61,7 +121,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
|
||||
loadFrames();
|
||||
return () => { cancelled = true; };
|
||||
}, [currentProject?.id, setFrames, setCurrentFrame]);
|
||||
}, [currentProject?.id, currentProject?.video_path, hydrateSavedAnnotations, setFrames, setCurrentFrame]);
|
||||
|
||||
const templates = useStore((state) => state.templates);
|
||||
const setTemplates = useStore((state) => state.setTemplates);
|
||||
@@ -72,7 +132,121 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}
|
||||
}, [templates.length, setTemplates]);
|
||||
|
||||
const currentFrameUrl = frames[currentFrameIndex]?.url || '';
|
||||
const currentFrame = frames[currentFrameIndex] || null;
|
||||
const frameById = useMemo(() => new Map(frames.map((frame) => [frame.id, frame])), [frames]);
|
||||
const projectFrameIds = useMemo(() => new Set(frames.map((frame) => frame.id)), [frames]);
|
||||
|
||||
const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => {
|
||||
if (!currentProject?.id) return 0;
|
||||
const projectMasks = masks.filter((mask) => projectFrameIds.has(mask.frameId));
|
||||
const pendingMasks = projectMasks.filter((mask) => !mask.annotationId);
|
||||
const dirtyMasks = projectMasks.filter((mask) => mask.annotationId && mask.saveStatus === 'dirty');
|
||||
if (pendingMasks.length === 0 && dirtyMasks.length === 0) {
|
||||
if (!silent) setStatusMessage('没有待保存标注');
|
||||
return 0;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatusMessage('正在保存标注...');
|
||||
try {
|
||||
const createPayloads = pendingMasks
|
||||
.map((mask) => {
|
||||
const frame = frameById.get(mask.frameId);
|
||||
return frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
|
||||
})
|
||||
.filter((payload): payload is NonNullable<typeof payload> => Boolean(payload));
|
||||
|
||||
const updatePayloads = dirtyMasks
|
||||
.map((mask) => {
|
||||
const frame = frameById.get(mask.frameId);
|
||||
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
|
||||
if (!payload || !mask.annotationId) return null;
|
||||
const updatePayload = {
|
||||
template_id: payload.template_id,
|
||||
mask_data: payload.mask_data,
|
||||
points: payload.points,
|
||||
bbox: payload.bbox,
|
||||
};
|
||||
return { annotationId: mask.annotationId, payload: updatePayload };
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||
|
||||
if (createPayloads.length === 0 && updatePayloads.length === 0) {
|
||||
setStatusMessage('没有可保存的标注数据');
|
||||
return 0;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...createPayloads.map((payload) => saveAnnotation(payload)),
|
||||
...updatePayloads.map(({ annotationId, payload }) => updateAnnotation(annotationId, payload)),
|
||||
]);
|
||||
await hydrateSavedAnnotations(currentProject.id, frames);
|
||||
const savedCount = createPayloads.length + updatePayloads.length;
|
||||
setStatusMessage(`已保存 ${savedCount} 个标注`);
|
||||
return savedCount;
|
||||
} catch (err) {
|
||||
console.error('Save annotations failed:', err);
|
||||
setStatusMessage('保存失败,请检查后端服务');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, masks, projectFrameIds]);
|
||||
|
||||
const handleClearCurrentFrameMasks = useCallback(async () => {
|
||||
if (!currentFrame) return;
|
||||
const frameMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
|
||||
const annotationIds = frameMasks
|
||||
.map((mask) => mask.annotationId)
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId));
|
||||
|
||||
setIsSaving(true);
|
||||
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...');
|
||||
try {
|
||||
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
|
||||
setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id));
|
||||
setStatusMessage(annotationIds.length > 0
|
||||
? `已删除 ${annotationIds.length} 个后端标注`
|
||||
: '已清空本帧未保存遮罩');
|
||||
} catch (err) {
|
||||
console.error('Delete annotations failed:', err);
|
||||
setStatusMessage('删除失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [currentFrame, masks, setMasks]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await savePendingAnnotations();
|
||||
} catch {
|
||||
// status message is set in savePendingAnnotations
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!currentProject?.id) return;
|
||||
setIsExporting(true);
|
||||
setStatusMessage('正在准备导出...');
|
||||
try {
|
||||
await savePendingAnnotations({ silent: true });
|
||||
const blob = await exportCoco(currentProject.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `project_${currentProject.id}_coco.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setStatusMessage('COCO JSON 已导出');
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
setStatusMessage('导出失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
|
||||
@@ -84,14 +258,25 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
<span className="text-sm text-white font-mono">{currentProject?.name || '未选择项目'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase font-medium">
|
||||
<span className="px-2 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">SAM 3 部署就绪</span>
|
||||
</div>
|
||||
<button className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white">
|
||||
导出 JSON 标注集
|
||||
{statusMessage && (
|
||||
<span className="text-[10px] text-gray-500 font-mono max-w-48 truncate" title={statusMessage}>
|
||||
{statusMessage}
|
||||
</span>
|
||||
)}
|
||||
<ModelStatusBadge />
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={!currentProject?.id || isExporting || isSaving}
|
||||
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? '导出中...' : '导出 JSON 标注集'}
|
||||
</button>
|
||||
<button className="px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium rounded-md transition-shadow shadow-lg shadow-cyan-900/20">
|
||||
结构化归档保存
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!currentProject?.id || isSaving || isExporting}
|
||||
className="px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium rounded-md transition-shadow shadow-lg shadow-cyan-900/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? '保存中...' : '结构化归档保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +287,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
|
||||
<div className="flex-1 relative flex items-center justify-center p-8 bg-[#151515] overflow-hidden">
|
||||
<div className="relative w-full h-full bg-[#1e1e1e] border border-white/5 shadow-2xl rounded-sm">
|
||||
<CanvasArea activeTool={activeTool} frameUrl={currentFrameUrl} />
|
||||
<CanvasArea activeTool={activeTool} frame={currentFrame} onClearMasks={handleClearCurrentFrameMasks} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user