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