feat: 完善 mask 编辑、传播平滑与开发重启闭环

功能增加:

- 新增后端 /api/ai/smooth-mask 接口,对当前 mask polygon 执行 Chaikin 边缘平滑,并返回 polygon、bbox、area 与拓扑锚点。

- 在右侧实例属性面板加入边缘平滑强度和应用边缘平滑操作,应用后将 mask 标记为 draft/dirty,并通过正常保存链路落库。

- 保存标注与传播 seed 时保留 geometry_smoothing 元数据,自动传播 forward/backward 结果保存前应用同一平滑参数。

- 自动传播 seed signature 纳入平滑参数,修改平滑强度后会触发旧同源传播结果清理并重新传播。

- 支持跨帧跟随同一传播链 mask,AI 推送回工作区时保留当前帧视角。

Bugfix:

- 修复中间帧向前传播时旧 forward/backward 同物体结果未被清理导致双重 mask 的问题。

- 修复 propagation worker 写入目标帧前只按旧方向清理导致 backward 重传残留的问题。

- 修复多边形顶点拖拽和编辑后画布视口异常移动的问题,并补充拖拽状态回写。

- 修复实例属性标题跟随全局 active class 而不是当前 mask label 的问题,并移除后端模型置信度展示。

开发与部署:

- 新增 restart_dev_services.sh,使用 setsid 独立后台重启 FastAPI、Celery 和前端,写入 pid/log 文件并做 3000/8000 健康检查。

- 明确后端或 Celery 相关改动完成后需要运行重启脚本,保证运行态加载最新代码。

测试与文档:

- 补充后端 smooth-mask、传播平滑 metadata、seed signature、传播去重方向覆盖等测试。

- 补充前端 OntologyInspector、VideoWorkspace、CanvasArea 和 api 契约测试,覆盖边缘平滑、传播参数、跨帧选区跟随和画布编辑行为。

- 更新 README、AGENTS、安装文档、前端元素审计、需求冻结、设计冻结和测试计划,记录当前真实行为与重启要求。
This commit is contained in:
2026-05-02 17:04:02 +08:00
parent f365539ff2
commit 4c1d3dba73
20 changed files with 1358 additions and 71 deletions

View File

@@ -4,7 +4,7 @@ import { useStore } from '../store/useStore';
import type { TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
import { getActiveTemplate } from '../lib/templateSelection';
import { analyzeMask, updateTemplate, type MaskAnalysisResult } from '../lib/api';
import { analyzeMask, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult } from '../lib/api';
export function OntologyInspector() {
const templates = useStore((state) => state.templates);
@@ -30,13 +30,17 @@ export function OntologyInspector() {
const [maskAnalysis, setMaskAnalysis] = useState<MaskAnalysisResult | null>(null);
const [isAnalyzingMask, setIsAnalyzingMask] = useState(false);
const [analysisMessage, setAnalysisMessage] = useState('');
const [smoothingStrength, setSmoothingStrength] = useState(0);
const [isSmoothingMask, setIsSmoothingMask] = useState(false);
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
const templateClasses = activeTemplate?.classes || [];
const allClasses = [...templateClasses].sort((a, b) => b.zIndex - a.zIndex);
const selectedMask = masks.find((mask) => selectedMaskIds.includes(mask.id)) || null;
const selectedMaskLabel = selectedMask?.className || selectedMask?.label || '未选择';
const currentFrame = frames[currentFrameIndex] || null;
const classButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const skipNextAutoAnalysisRef = useRef(false);
const selectedMaskClass = useMemo(() => {
if (!selectedMask) return null;
@@ -119,11 +123,68 @@ export function OntologyInspector() {
};
React.useEffect(() => {
if (skipNextAutoAnalysisRef.current) {
skipNextAutoAnalysisRef.current = false;
return;
}
void refreshMaskAnalysis(false);
// selectedMask is intentionally tracked by id and geometry fields to avoid
// re-running analysis for unrelated store changes.
}, [selectedMask?.id, selectedMask?.segmentation, selectedMask?.points, currentFrame?.id]);
React.useEffect(() => {
const smoothing = selectedMask?.metadata?.geometry_smoothing;
const strength = smoothing && typeof smoothing === 'object'
? Number((smoothing as Record<string, unknown>).strength)
: 0;
setSmoothingStrength(Number.isFinite(strength) ? Math.min(Math.max(strength, 0), 100) : 0);
}, [selectedMask?.id]);
const handleApplySmoothing = async () => {
if (!selectedMask || !currentFrame) {
setAnalysisMessage('请选择一个 mask 后再应用边缘平滑');
return;
}
setIsSmoothingMask(true);
setAnalysisMessage('');
try {
const result = await smoothMaskGeometry(selectedMask, currentFrame, smoothingStrength);
skipNextAutoAnalysisRef.current = true;
setMasks(masks.map((mask) => {
if (mask.id !== selectedMask.id) return mask;
return {
...mask,
pathData: result.pathData,
segmentation: result.segmentation,
bbox: result.bbox,
area: result.area,
metadata: {
...(mask.metadata || {}),
geometry_smoothing: result.smoothing,
},
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
saved: mask.annotationId ? false : mask.saved,
};
}));
setMaskAnalysis({
confidence: null,
confidence_source: 'manual_or_imported',
topology_anchor_count: result.topology_anchor_count,
topology_anchors: result.topology_anchors,
area: result.area,
bbox: result.bbox,
source: selectedMask.metadata?.source as string | undefined,
message: result.message,
});
setAnalysisMessage(`${result.message},请保存后生效`);
} catch (err) {
console.error('Mask smoothing failed:', err);
setAnalysisMessage('边缘平滑失败,请检查后端服务');
} finally {
setIsSmoothingMask(false);
}
};
const handleAddCustom = async () => {
if (!newClassName.trim()) return;
if (!activeTemplate) {
@@ -301,7 +362,7 @@ export function OntologyInspector() {
<div className="flex items-center gap-2 mb-3">
<Tag size={12} className="text-cyan-400" />
<span className="text-xs font-semibold text-gray-200">
{activeClass?.name || activeTemplate?.name || '未选择'}
{selectedMaskLabel}
</span>
</div>
<div className="space-y-3">
@@ -309,22 +370,35 @@ export function OntologyInspector() {
<span className="text-[10px] text-gray-500 uppercase">:</span>
<span className="text-xs font-mono text-gray-300">{selectedMaskIds.length}</span>
</div>
<div className="space-y-1">
<label className="text-[10px] text-gray-500 uppercase"></label>
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-green-500"
style={{ width: `${Math.round((maskAnalysis?.confidence ?? 0) * 100)}%` }}
/>
</div>
<div className="text-[10px] font-mono text-green-500 text-right">
{maskAnalysis?.confidence != null ? maskAnalysis.confidence.toFixed(4) : '无模型分数'}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-500 uppercase">:</span>
<span className="text-xs font-mono text-gray-300">{maskAnalysis?.topology_anchor_count ?? 0} </span>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="mask-edge-smoothing" className="text-[10px] text-gray-500 uppercase">:</label>
<span className="text-xs font-mono text-gray-300">{smoothingStrength}%</span>
</div>
<input
id="mask-edge-smoothing"
aria-label="边缘平滑强度"
type="range"
min={0}
max={100}
step={5}
value={smoothingStrength}
onChange={(event) => setSmoothingStrength(Number(event.target.value))}
disabled={!selectedMask || isSmoothingMask}
className="w-full accent-cyan-500 disabled:opacity-40"
/>
<button
onClick={handleApplySmoothing}
disabled={!selectedMask || !currentFrame || isSmoothingMask}
className="mt-2 w-full bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/20 text-xs text-cyan-100 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSmoothingMask ? '平滑中...' : '应用边缘平滑'}
</button>
</div>
{analysisMessage && (
<div className="text-[10px] leading-relaxed text-gray-500">{analysisMessage}</div>
)}