完善项目导入、模板与分割工作区交互
- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。 - 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。 - 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。 - 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。 - 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。 - 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。 - 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。 - 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
This commit is contained in:
@@ -22,7 +22,7 @@ import { ToolsPalette } from './ToolsPalette';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
import { FrameTimeline } from './FrameTimeline';
|
||||
import { ModelStatusBadge } from './ModelStatusBadge';
|
||||
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type TemplateClass } from '../store/useStore';
|
||||
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type Template, type TemplateClass } from '../store/useStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { normalizeClassMaskIds } from '../lib/maskIds';
|
||||
|
||||
@@ -44,6 +44,14 @@ type PropagationHistorySegment = {
|
||||
};
|
||||
type RangeSelectionMode = 'propagation' | 'clear' | 'export' | null;
|
||||
type ClearRangeMode = 'all' | 'propagated_only';
|
||||
type ClearRangeConfirmState = {
|
||||
frameIdsToClear: string[];
|
||||
annotationIds: string[];
|
||||
maskCount: number;
|
||||
rangeStartIndex: number;
|
||||
rangeEndIndex: number;
|
||||
mode: ClearRangeMode;
|
||||
};
|
||||
type GtUnknownPolicy = 'discard' | 'undefined';
|
||||
type ExportScope = 'all' | 'range' | 'current';
|
||||
type ExportPreviewPolygon = {
|
||||
@@ -66,7 +74,7 @@ type GtMaskPreviewState = {
|
||||
validationSkipped?: boolean;
|
||||
};
|
||||
|
||||
const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传灰度图,或 RGB 三通道完全相同的 maskid 图(背景 0,像素值为 maskid)。';
|
||||
const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传 8-bit 灰度图,或 8-bit RGB 三通道完全相同的 maskid 图(背景 0,像素值为 1-255 的 maskid)。';
|
||||
|
||||
const flatPolygonToSvgPoints = (polygon: number[]) => {
|
||||
const points: string[] = [];
|
||||
@@ -115,6 +123,66 @@ const classByMaskId = (classes: TemplateClass[]) => new Map(
|
||||
normalizeClassMaskIds(classes).map((templateClass) => [Number(templateClass.maskId), templateClass]),
|
||||
);
|
||||
|
||||
const UNCLASSIFIED_MASK_LABEL = '待分类';
|
||||
const UNCLASSIFIED_MASK_COLOR = '#9ca3af';
|
||||
|
||||
const normalizeMaskAgainstTemplates = (mask: Mask, templates: Template[]): Mask => {
|
||||
const hasClassReference = Boolean(mask.classId || mask.className || mask.classMaskId !== undefined);
|
||||
if (!hasClassReference || mask.classMaskId === 0) return mask;
|
||||
|
||||
const template = mask.templateId
|
||||
? templates.find((item) => String(item.id) === String(mask.templateId))
|
||||
: null;
|
||||
if (!template) return mask;
|
||||
|
||||
const classes = normalizeClassMaskIds(template.classes || []);
|
||||
let matchedClass: TemplateClass | undefined;
|
||||
if (mask.classId) {
|
||||
matchedClass = classes.find((templateClass) => templateClass.id === mask.classId);
|
||||
} else if (mask.classMaskId !== undefined) {
|
||||
matchedClass = classes.find((templateClass) => Number(templateClass.maskId) === Number(mask.classMaskId));
|
||||
} else if (mask.className) {
|
||||
matchedClass = classes.find((templateClass) => (
|
||||
templateClass.name === mask.className
|
||||
&& (!mask.color || templateClass.color.toLowerCase() === mask.color.toLowerCase())
|
||||
)) || classes.find((templateClass) => templateClass.name === mask.className);
|
||||
}
|
||||
|
||||
if (matchedClass) {
|
||||
return {
|
||||
...mask,
|
||||
classId: matchedClass.id,
|
||||
className: matchedClass.name,
|
||||
classZIndex: matchedClass.zIndex,
|
||||
classMaskId: matchedClass.maskId,
|
||||
label: matchedClass.name,
|
||||
color: matchedClass.color,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...mask,
|
||||
classId: undefined,
|
||||
className: UNCLASSIFIED_MASK_LABEL,
|
||||
classZIndex: undefined,
|
||||
classMaskId: 0,
|
||||
label: UNCLASSIFIED_MASK_LABEL,
|
||||
color: UNCLASSIFIED_MASK_COLOR,
|
||||
saveStatus: mask.annotationId ? 'dirty' : 'draft',
|
||||
saved: mask.annotationId ? false : mask.saved,
|
||||
metadata: {
|
||||
...(mask.metadata || {}),
|
||||
needs_classification: true,
|
||||
stale_class: {
|
||||
id: mask.classId,
|
||||
name: mask.className || mask.label,
|
||||
maskId: mask.classMaskId,
|
||||
color: mask.color,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const trimPropagationHistoryByClearedRange = (
|
||||
segments: PropagationHistorySegment[],
|
||||
clearStartFrame: number,
|
||||
@@ -147,6 +215,62 @@ const trimPropagationHistoryByClearedRange = (
|
||||
});
|
||||
};
|
||||
|
||||
const prunePropagationHistoryByActiveFrames = (
|
||||
segments: PropagationHistorySegment[],
|
||||
activeFrameNumbers: Set<number>,
|
||||
totalFrames: number,
|
||||
): PropagationHistorySegment[] => (
|
||||
segments.flatMap((segment) => {
|
||||
const start = Math.max(1, Math.min(segment.startFrame, segment.endFrame));
|
||||
const end = Math.min(totalFrames, Math.max(segment.startFrame, segment.endFrame));
|
||||
const chunks: PropagationHistorySegment[] = [];
|
||||
let chunkStart: number | null = null;
|
||||
|
||||
for (let frameNumber = start; frameNumber <= end; frameNumber += 1) {
|
||||
if (activeFrameNumbers.has(frameNumber)) {
|
||||
chunkStart ??= frameNumber;
|
||||
continue;
|
||||
}
|
||||
if (chunkStart !== null) {
|
||||
const chunkEnd = frameNumber - 1;
|
||||
chunks.push({
|
||||
...segment,
|
||||
id: chunkStart === start && chunkEnd === end ? segment.id : `${segment.id}-${chunkStart}-${chunkEnd}`,
|
||||
startFrame: chunkStart,
|
||||
endFrame: chunkEnd,
|
||||
});
|
||||
chunkStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (chunkStart !== null) {
|
||||
chunks.push({
|
||||
...segment,
|
||||
id: chunkStart === start ? segment.id : `${segment.id}-${chunkStart}-${end}`,
|
||||
startFrame: chunkStart,
|
||||
endFrame: end,
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
})
|
||||
);
|
||||
|
||||
const propagationHistoryEqual = (
|
||||
left: PropagationHistorySegment[],
|
||||
right: PropagationHistorySegment[],
|
||||
) => (
|
||||
left.length === right.length
|
||||
&& left.every((segment, index) => {
|
||||
const other = right[index];
|
||||
return other
|
||||
&& segment.id === other.id
|
||||
&& segment.startFrame === other.startFrame
|
||||
&& segment.endFrame === other.endFrame
|
||||
&& segment.colorIndex === other.colorIndex
|
||||
&& segment.label === other.label;
|
||||
})
|
||||
);
|
||||
|
||||
const isPropagatedMask = (mask: Mask) => {
|
||||
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : '';
|
||||
return source.includes('_propagation')
|
||||
@@ -156,6 +280,43 @@ const isPropagatedMask = (mask: Mask) => {
|
||||
|| mask.metadata?.propagation_seed_key !== undefined;
|
||||
};
|
||||
|
||||
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
|
||||
if (!metadata) return {};
|
||||
const {
|
||||
geometry_smoothing: _geometrySmoothing,
|
||||
geometry_smoothing_preview: _geometrySmoothingPreview,
|
||||
...rest
|
||||
} = metadata;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const isNotFoundError = (error: unknown) => (
|
||||
typeof error === 'object'
|
||||
&& error !== null
|
||||
&& (
|
||||
('response' in error
|
||||
&& typeof (error as { response?: { status?: unknown } }).response === 'object'
|
||||
&& (error as { response?: { status?: unknown } }).response?.status === 404)
|
||||
|| ('status' in error && (error as { status?: unknown }).status === 404)
|
||||
)
|
||||
);
|
||||
|
||||
const deleteAnnotationIfExists = async (annotationId: string) => {
|
||||
try {
|
||||
await deleteAnnotation(annotationId);
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnnotationsIfExist = async (annotationIds: string[]) => {
|
||||
const results = await Promise.allSettled(annotationIds.map((annotationId) => deleteAnnotationIfExists(annotationId)));
|
||||
const firstFailure = results.find((result): result is PromiseRejectedResult => (
|
||||
result.status === 'rejected' && !isNotFoundError(result.reason)
|
||||
));
|
||||
if (firstFailure) throw firstFailure.reason;
|
||||
};
|
||||
|
||||
const PROPAGATION_POLL_INTERVAL_MS = 250;
|
||||
const STATUS_MESSAGE_TTL_MS = 3600;
|
||||
|
||||
@@ -272,6 +433,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
|
||||
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
|
||||
const [clearRangeMode, setClearRangeMode] = useState<ClearRangeMode>('all');
|
||||
const [pendingClearRangeConfirm, setPendingClearRangeConfirm] = useState<ClearRangeConfirmState | null>(null);
|
||||
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
|
||||
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
|
||||
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
|
||||
@@ -317,6 +479,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
return () => window.removeEventListener('keydown', handleWorkspaceShortcuts);
|
||||
}, [redoMasks, undoMasks]);
|
||||
|
||||
const templates = useStore((state) => state.templates);
|
||||
const setTemplates = useStore((state) => state.setTemplates);
|
||||
|
||||
const hydrateSavedAnnotations = useCallback(async (
|
||||
projectId: string,
|
||||
projectFrames: Frame[],
|
||||
@@ -326,11 +491,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const frameById = new Map(projectFrames.map((frame) => [frame.id, frame]));
|
||||
const projectFrameIds = new Set(projectFrames.map((frame) => frame.id));
|
||||
const excludedDraftIds = new Set(excludeUnsavedMaskIds);
|
||||
let latestTemplates = useStore.getState().templates;
|
||||
if (latestTemplates.length === 0) {
|
||||
latestTemplates = await getTemplates();
|
||||
setTemplates(latestTemplates);
|
||||
}
|
||||
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;
|
||||
const mask = frame ? annotationToMask(annotation, frame) : null;
|
||||
return mask ? normalizeMaskAgainstTemplates(mask, latestTemplates) : null;
|
||||
})
|
||||
.filter((mask): mask is NonNullable<typeof mask> => Boolean(mask));
|
||||
const currentMasks = useStore.getState().masks;
|
||||
@@ -346,7 +517,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setSelectedMaskIds(nextSelectedIds);
|
||||
}
|
||||
}
|
||||
}, [setMasks, setSelectedMaskIds]);
|
||||
}, [setMasks, setSelectedMaskIds, setTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProject?.id) return;
|
||||
@@ -408,9 +579,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
return () => { cancelled = true; };
|
||||
}, [currentProject?.id, currentProject?.video_path, hydrateSavedAnnotations, setFrames, setCurrentFrame]);
|
||||
|
||||
const templates = useStore((state) => state.templates);
|
||||
const setTemplates = useStore((state) => state.setTemplates);
|
||||
|
||||
useEffect(() => {
|
||||
if (templates.length === 0) {
|
||||
getTemplates().then((data) => setTemplates(data)).catch(console.error);
|
||||
@@ -451,6 +619,25 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
})));
|
||||
}, [exportPreviewFrame, masks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (propagationHistory.length === 0 || frames.length === 0) return;
|
||||
const frameNumberById = new Map(frames.map((frame, index) => [String(frame.id), index + 1]));
|
||||
const activePropagatedFrameNumbers = new Set<number>();
|
||||
masks.forEach((mask) => {
|
||||
if (!isPropagatedMask(mask)) return;
|
||||
const frameNumber = frameNumberById.get(String(mask.frameId));
|
||||
if (frameNumber) activePropagatedFrameNumbers.add(frameNumber);
|
||||
});
|
||||
const nextHistory = prunePropagationHistoryByActiveFrames(
|
||||
propagationHistory,
|
||||
activePropagatedFrameNumbers,
|
||||
totalFrames,
|
||||
);
|
||||
if (!propagationHistoryEqual(propagationHistory, nextHistory)) {
|
||||
setPropagationHistory(nextHistory);
|
||||
}
|
||||
}, [frames, masks, propagationHistory, totalFrames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined;
|
||||
const timer = window.setTimeout(() => setStatusMessage(''), STATUS_MESSAGE_TTL_MS);
|
||||
@@ -474,9 +661,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setHasExplicitPropagationRange(false);
|
||||
}, [currentFrameNumber, totalFrames]);
|
||||
|
||||
const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => {
|
||||
const savePendingAnnotations = useCallback(async ({ silent = false, frameId }: { silent?: boolean; frameId?: string } = {}) => {
|
||||
if (!currentProject?.id) return 0;
|
||||
const projectMasks = masks.filter((mask) => projectFrameIds.has(mask.frameId));
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const projectMasks = latestMasks.filter((mask) => (
|
||||
projectFrameIds.has(mask.frameId)
|
||||
&& (!frameId || String(mask.frameId) === String(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) {
|
||||
@@ -500,13 +691,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const frame = frameById.get(mask.frameId);
|
||||
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
|
||||
if (!payload || !mask.annotationId) return null;
|
||||
const propagationLineage = {
|
||||
...(mask.metadata?.source_annotation_id !== undefined ? { source_annotation_id: mask.metadata.source_annotation_id } : {}),
|
||||
...(mask.metadata?.source_mask_id !== undefined ? { source_mask_id: mask.metadata.source_mask_id } : {}),
|
||||
};
|
||||
const savedMetadata = persistentMaskMetadata(mask.metadata);
|
||||
const updatePayload = {
|
||||
template_id: payload.template_id,
|
||||
mask_data: { ...payload.mask_data, ...propagationLineage },
|
||||
mask_data: { ...savedMetadata, ...payload.mask_data },
|
||||
points: payload.points,
|
||||
bbox: payload.bbox,
|
||||
};
|
||||
@@ -539,7 +727,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, masks, projectFrameIds]);
|
||||
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, projectFrameIds]);
|
||||
|
||||
const handleClearCurrentFrameMasks = useCallback(async () => {
|
||||
if (!currentFrame) return;
|
||||
@@ -551,7 +739,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setIsSaving(true);
|
||||
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...');
|
||||
try {
|
||||
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
|
||||
await deleteAnnotationsIfExist(annotationIds);
|
||||
setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id));
|
||||
setStatusMessage(annotationIds.length > 0
|
||||
? `已删除 ${annotationIds.length} 个后端标注`
|
||||
@@ -564,6 +752,39 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}
|
||||
}, [currentFrame, masks, setMasks]);
|
||||
|
||||
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
|
||||
const frameIdsToClear = new Set(request.frameIdsToClear);
|
||||
setIsSaving(true);
|
||||
setStatusMessage(request.annotationIds.length > 0
|
||||
? `正在删除第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的已保存标注...`
|
||||
: `正在清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的本地遮罩...`);
|
||||
try {
|
||||
await deleteAnnotationsIfExist(request.annotationIds);
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const clearedMaskIds = new Set(
|
||||
latestMasks
|
||||
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
|
||||
.filter((mask) => request.mode === 'all' || isPropagatedMask(mask))
|
||||
.map((mask) => mask.id),
|
||||
);
|
||||
setMasks(latestMasks.filter((mask) => !clearedMaskIds.has(mask.id)));
|
||||
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
|
||||
setPropagationHistory((previous) => trimPropagationHistoryByClearedRange(previous, request.rangeStartIndex + 1, request.rangeEndIndex + 1));
|
||||
setStatusMessage(request.mode === 'propagated_only'
|
||||
? `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个自动传播遮罩,其中后端标注 ${request.annotationIds.length} 个,人工/AI 标注帧已保留`
|
||||
: `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个遮罩,其中后端标注 ${request.annotationIds.length} 个`);
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode(null);
|
||||
setHasExplicitPropagationRange(false);
|
||||
setPendingClearRangeConfirm(null);
|
||||
} catch (err) {
|
||||
console.error('Delete range annotations failed:', err);
|
||||
setStatusMessage('批量清空失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [setMasks, setSelectedMaskIds]);
|
||||
|
||||
const handleClearFrameRangeMasks = useCallback(async () => {
|
||||
if (rangeSelectionMode !== 'clear') {
|
||||
setIsPropagationRangeSelecting(true);
|
||||
@@ -595,57 +816,34 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
return;
|
||||
}
|
||||
const hasManualOrAiAnnotatedFrames = clearRangeMode === 'all' && rangeMasks.some((mask) => !isPropagatedMask(mask));
|
||||
if (hasManualOrAiAnnotatedFrames) {
|
||||
const confirmed = window.confirm('是否清除“人工/AI标注帧”?\n该范围包含人工绘制或 AI 智能分割生成的 mask,确认后这些 mask 也会被删除。');
|
||||
if (!confirmed) {
|
||||
setStatusMessage('已取消清空片段遮罩');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const annotationIds = Array.from(new Set(
|
||||
rangeMasks
|
||||
.map((mask) => mask.annotationId)
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId)),
|
||||
));
|
||||
|
||||
setIsSaving(true);
|
||||
setStatusMessage(annotationIds.length > 0
|
||||
? `正在删除第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的已保存标注...`
|
||||
: `正在清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的本地遮罩...`);
|
||||
try {
|
||||
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const clearedMaskIds = new Set(
|
||||
latestMasks
|
||||
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
|
||||
.filter((mask) => clearRangeMode === 'all' || isPropagatedMask(mask))
|
||||
.map((mask) => mask.id),
|
||||
);
|
||||
setMasks(latestMasks.filter((mask) => !clearedMaskIds.has(mask.id)));
|
||||
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
|
||||
setPropagationHistory((previous) => trimPropagationHistoryByClearedRange(previous, rangeStartIndex + 1, rangeEndIndex + 1));
|
||||
setStatusMessage(clearRangeMode === 'propagated_only'
|
||||
? `已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个自动传播遮罩,其中后端标注 ${annotationIds.length} 个,人工/AI 标注帧已保留`
|
||||
: `已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length} 个`);
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode(null);
|
||||
setHasExplicitPropagationRange(false);
|
||||
} catch (err) {
|
||||
console.error('Delete range annotations failed:', err);
|
||||
setStatusMessage('批量清空失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
const request = {
|
||||
frameIdsToClear: Array.from(frameIdsToClear),
|
||||
annotationIds,
|
||||
maskCount: rangeMasks.length,
|
||||
rangeStartIndex,
|
||||
rangeEndIndex,
|
||||
mode: clearRangeMode,
|
||||
};
|
||||
if (hasManualOrAiAnnotatedFrames) {
|
||||
setPendingClearRangeConfirm(request);
|
||||
return;
|
||||
}
|
||||
}, [clearRangeMode, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, setMasks, setSelectedMaskIds, totalFrames]);
|
||||
await executeClearFrameRange(request);
|
||||
}, [clearRangeMode, executeClearFrameRange, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, totalFrames]);
|
||||
|
||||
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
|
||||
if (annotationIds.length === 0) return;
|
||||
try {
|
||||
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
|
||||
setStatusMessage(`已删除 ${annotationIds.length} 个被合并标注`);
|
||||
await deleteAnnotationsIfExist(annotationIds);
|
||||
setStatusMessage(`已删除 ${annotationIds.length} 个标注`);
|
||||
} catch (err) {
|
||||
console.error('Delete merged annotations failed:', err);
|
||||
setStatusMessage('合并后删除原标注失败,请检查后端服务');
|
||||
console.error('Delete annotations failed:', err);
|
||||
setStatusMessage('删除标注失败,请检查后端服务');
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
@@ -990,21 +1188,21 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
|
||||
const runAutoPropagate = async () => {
|
||||
if (!currentProject?.id || !currentFrame?.id) return;
|
||||
const initialSeedMasks = masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
|
||||
const initialSeedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
|
||||
if (initialSeedMasks.length === 0) {
|
||||
setStatusMessage('请先在当前参考帧创建或保存至少一个 mask');
|
||||
setStatusMessage('当前参考帧无遮罩');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUnstableSeedMasks = initialSeedMasks.some((mask) => !mask.annotationId || mask.saveStatus === 'dirty');
|
||||
if (hasUnstableSeedMasks) {
|
||||
setStatusMessage('正在先保存参考帧 mask,确保二次传播可以替换旧结果...');
|
||||
await savePendingAnnotations({ silent: true });
|
||||
await savePendingAnnotations({ silent: true, frameId: currentFrame.id });
|
||||
}
|
||||
|
||||
const seedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
|
||||
if (seedMasks.length === 0) {
|
||||
setStatusMessage('参考帧 mask 保存后未能回显,请先检查归档保存是否成功');
|
||||
setStatusMessage('当前参考帧无遮罩');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1285,7 +1483,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
<div className="w-full max-w-xl rounded-md border border-white/10 bg-[#151515] p-4 shadow-2xl shadow-black/60">
|
||||
<div className="text-sm font-semibold text-white">导入 GT Mask</div>
|
||||
<div className="mt-2 text-xs leading-5 text-gray-400">
|
||||
GT 图片必须是灰度 maskid 图,或 RGB 三通道完全相同的 [X,X,X] maskid 图;0 为背景,X 为类别 maskid。尺寸不一致时会按当前帧长宽自动最近邻拉伸。
|
||||
GT 图片必须是 8-bit 灰度 maskid 图,或 8-bit RGB 三通道完全相同的 [X,X,X] maskid 图;0 为背景,X 为 1-255 的类别 maskid。尺寸不一致时会按当前帧长宽自动最近邻拉伸。
|
||||
</div>
|
||||
<div className="mt-3 rounded border border-white/10 bg-black/20 px-3 py-2 text-[11px] text-gray-500">
|
||||
{pendingGtImportFile.name}
|
||||
@@ -1663,6 +1861,42 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
<OntologyInspector />
|
||||
</div>
|
||||
|
||||
{pendingClearRangeConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||
<h2 className="text-lg font-semibold text-white">清除人工/AI 标注帧</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||||
第 {pendingClearRangeConfirm.rangeStartIndex + 1}-{pendingClearRangeConfirm.rangeEndIndex + 1} 帧包含人工绘制或 AI 智能分割生成的 mask。
|
||||
继续后会清空该范围内共 {pendingClearRangeConfirm.maskCount} 个遮罩。
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-red-200/70">
|
||||
如只想删除自动传播内容,请取消后选择“保留人工/AI”。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingClearRangeConfirm(null);
|
||||
setStatusMessage('已取消清空片段遮罩');
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void executeClearFrameRange(pendingClearRangeConfirm)}
|
||||
disabled={isSaving}
|
||||
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
|
||||
>
|
||||
确认清除人工/AI 标注
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Timeline */}
|
||||
<FrameTimeline
|
||||
propagationRange={visibleTimelineRange}
|
||||
|
||||
Reference in New Issue
Block a user