# 当前设计冻结文档

冻结日期：2026-05-01

本文档描述当前代码结构、数据流、接口契约和测试边界。后续实现如果改变这些设计，应同步更新本文档和测试。

## 总体架构

当前系统由三层组成：

- React + TypeScript 前端 SPA。
- FastAPI 后端 API。
- PostgreSQL、MinIO、Redis、SAM 2 等外部基础设施。SAM 3 相关源码保留，但当前产品入口禁用。

开发时前端通过 `server.ts` 启动 Express + Vite middleware；后端通过 `backend/main.py` 启动 FastAPI。前端业务接口访问 FastAPI，`server.ts` 不再保留旧版 `/api/*` mock。

## 前端模块

| 模块 | 文件 | 设计职责 |
|------|------|----------|
| 应用入口 | `src/App.tsx` | 根据登录状态和 `activeModule` 切换页面 |
| 全局状态 | `src/store/useStore.ts` | Zustand store，保存项目、帧、模板、mask、当前选中 mask ids、工具状态和 mask 撤销/重做历史栈 |
| API 封装 | `src/lib/api.ts` | Axios 客户端、字段映射、AI 响应转换 |
| 配置 | `src/lib/config.ts` | 推导 API 和 WebSocket 地址 |
| WebSocket | `src/lib/websocket.ts` | 进度流连接、订阅、连接状态通知、心跳和重连 |
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态；左侧 Sidebar 底部使用 compact 形态显示 GPU/CPU 状态，工作区顶栏不再重复显示，具体传播权重只在进入自动传播后由顶栏下拉负责 |
| 登录页 | `src/components/Login.tsx` | 调用登录 API，写入 store |
| Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 |
| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、重命名、删除、导入视频/DICOM、显式生成帧 |
| 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板，组织工具栏、Canvas、本体面板、时间轴 |
| Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask |
| 工具栏 | `src/components/ToolsPalette.tsx` | 切换工作区编辑工具、在“重叠区域去除”后触发当前帧/传播链清空、GT Mask 导入和 AI 页面跳转；AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割；不再放置 AI 正/反点和框选工具，也不重复放置撤销/重做；拖拽/选择到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、清空遮罩/导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔；紧凑垂直布局，高度不足时自身滚动；外层宽 56px，按钮列固定 48px，滚动条使用右侧外扩空间和低对比 `seg-scrollbar` |
| 工作区顶栏 | `src/components/VideoWorkspace.tsx` | 保存状态按钮（“保存 X 个改动”/“已全部保存”）、导出/传播/按起止帧批量清空遮罩、显式撤销/重做按钮和工作区快捷键 |
| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、播放进度、视频处理进度条、自动传播历史片段、自动传播/布尔操作/导出范围选择、左右方向键切帧、播放和当前/总时长显示 |
| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、工作区 mask 透明度、分类树、后端自定义分类、mask 后端属性分析；内容过长时自身滚动，滚动条使用低对比 `seg-scrollbar` |
| AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图，使用当前项目帧 |
| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、详情页和编辑弹窗拖拽排序 |
| 短提示浮层 | `src/components/TransientNotice.tsx` | 项目库和模板库的非阻塞成功/失败提示，自动消失 |

## 后端模块

| 模块 | 文件 | 设计职责 |
|------|------|----------|
| 应用入口 | `backend/main.py` | FastAPI app、CORS、路由注册、健康检查、WebSocket |
| 配置 | `backend/config.py` | Pydantic settings |
| 数据库 | `backend/database.py` | SQLAlchemy engine、session、Base |
| 模型 | `backend/models.py` | User、Project、Frame、Template、Annotation、Mask、AuditLog、ProcessingTask |
| Schema | `backend/schemas.py` | Pydantic 请求/响应模型 |
| Auth | `backend/routers/auth.py` | 用户表、密码哈希、JWT 登录和 `/api/auth/me` |
| Admin | `backend/routers/admin.py` | 管理员用户 CRUD、角色/密码/启停用和审计日志 |
| Projects | `backend/routers/projects.py` | 项目与帧 CRUD |
| Templates | `backend/routers/templates.py` | 模板 CRUD 和 mapping_rules 打包/解包 |
| Media | `backend/routers/media.py` | 上传媒体和拆帧 |
| AI | `backend/routers/ai.py` | 当前启用 SAM 2 推理、视频传播、模型状态和标注保存 |
| 传播任务 | `backend/services/propagation_task_runner.py` | Celery 中执行自动传播 steps，写任务进度并保存传播标注 |
| Export | `backend/routers/export.py` | COCO 和 PNG mask 导出 |
| SAM 2 | `backend/services/sam2_engine.py` | SAM 2 懒加载、状态检测、点/框/自动推理和视频 mask 传播 |
| SAM 3 | `backend/services/sam3_engine.py`, `backend/services/sam3_external_worker.py`, `backend/setup_sam3_env.sh` | 历史保留的 SAM 3 桥接源码和脚本；当前未接入 registry |
| SAM Registry | `backend/services/sam_registry.py` | 当前暴露 SAM 2.1 四个变体、GPU 状态和推理分发 |

## 状态模型

前端 store 的核心对象：

- `Project`：项目基本信息、状态、帧数、fps、媒体路径。
- `Frame`：帧 ID、项目 ID、索引、图片 URL、宽高、序列时间戳和原视频源帧号。
- `Template` / `TemplateClass`：模板和分类定义。
- `Mask`：前端渲染用 mask，包含 `pathData`、`segmentation`、`bbox`、`area`。
- `selectedMaskIds`：Canvas 当前选中的 mask id 列表，供右侧本体面板对已选区域直接换标签。
- `maskHistory` / `maskFuture`：mask 编辑历史栈，用于撤销和重做。
- `activeModule`：当前页面。
- `activeTool`：当前工具。
- `aiModel`：当前启用的 AI 模型，取值为 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus` 或 `sam2.1_hiera_large`，默认 `sam2.1_hiera_tiny`。

## 关键数据流

### 登录

1. `Login` 收集用户名和密码。
2. `login()` 调用 `POST /api/auth/login`。
3. 后端用 `users` 表中的密码哈希校验用户，成功后返回签名 JWT 和用户资料。
4. 前端把 token 写入 `localStorage` 和 Zustand；刷新页面时 `useStore` 会从 `localStorage` 恢复 token。
5. `App` 在已登录状态调用 `/api/auth/me` 恢复当前用户，再拉取当前用户项目列表。

### 用户隔离

1. `Project.owner_user_id` 指向 `users.id`；启动时默认 admin 用户会被创建，历史 `owner_user_id IS NULL` 的项目会迁移归属到 admin。
2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。
3. `Template.owner_user_id` 支持用户模板；`owner_user_id IS NULL` 的模板视为系统模板，可作为默认分类体系对用户可见。
4. 角色分为 `admin`、`annotator`、`viewer`：`admin/annotator` 可调用写入类业务接口，`viewer` 只能调用读接口；`/api/admin/*` 仅允许 `admin`。
5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示，调用 `/api/admin/users` 完成新增、角色修改、停用/启用、密码修改和删除无项目用户，调用 `/api/admin/audit-logs` 展示登录和管理操作审计；改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认，恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`。
6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`，会重置默认 admin 密码/角色/启用状态，删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计，重新创建 `settings.demo_video_path` 指向的演示视频项目，以及 `settings.demo_dicom_dir` 指向的演示 DICOM 项目；DICOM 会按文件名自然顺序上传和生成帧；系统模板保留以保证重置后仍可标注。
7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401，权限不足返回 403，其他用户项目资源对当前用户表现为 404。

### 项目导入与生成帧

1. `ProjectLibrary` 创建项目。
2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目；该步骤不调用 `/api/media/parse`。上传期间项目库显示导入进度条、百分比和已上传字节，完成后短暂显示“视频导入完成”。
3. 用户在项目卡片点击“生成帧”，在弹窗中选择目标 FPS。
4. 前端调用 `/api/media/parse` 创建异步拆帧任务；可通过 `parse_fps`、`max_frames` 和 `target_width` 指定标准帧序列参数。
5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧；DICOM 在前端选择、后端上传、worker 下载和 pydicom 读取时都按文件名自然顺序排序；视频/DICOM 解析结果都按 `frame_%06d.jpg` 从 `frame_000000.jpg` 连续命名，视频帧按目标宽度缩放。
6. worker 写入 `frames.timestamp_ms` 和 `frames.source_frame_number`，并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。
7. worker 持续更新 `processing_tasks`，并发布 Redis `seg:progress`。
8. 刷新项目列表；项目卡片右上角 FPS 徽标显示生成关键帧序列时选择的 `parse_fps`，原始视频 FPS 仅作为底部“原 xx fps”辅助信息显示。
9. 导入视频、生成帧、上传 DICOM 和失败反馈使用 `TransientNotice`，不再使用浏览器 `alert()` 阻塞操作；提示默认数秒后自动消失。视频和 DICOM 上传阶段额外显示项目库内的导入进度面板，DICOM 面板显示有效文件数量，并在上传完成后切换为解析任务进度，轮询 `GET /api/tasks/{task_id}` 直到成功、失败或取消。
10. DICOM 和视频帧序列写入同一 `frames` 表并共用工作区、时间轴、AI 传播、标注保存、GT 导入和导出链路，差异只存在于项目库导入入口和后端解析器。

### 任务控制

1. Dashboard 从 `GET /api/dashboard/overview` 读取 queued/running/success/failed/cancelled 任务；queued/running 代表当前进度，success/failed/cancelled 代表最近任务状态。
2. 用户取消任务时，前端调用 `POST /api/tasks/{task_id}/cancel`；后端写入 `cancelled`、设置 `finished_at`，并尝试 `celery_app.control.revoke(..., terminate=True)`。
3. worker 在下载、解析、上传、写帧等关键阶段刷新任务状态；如果发现 `cancelled`，停止后续写入并发布 cancelled 事件。
4. 用户重试任务时，前端调用 `POST /api/tasks/{task_id}/retry`；后端基于原任务 `payload` 创建新任务，记录 `retry_of` 并重新投递 Celery。
5. 用户打开详情时，前端调用 `GET /api/tasks/{task_id}`，弹窗展示 error、payload、result、Celery ID 和时间。
6. Dashboard 通过 `/ws/progress` 接收 Redis `seg:progress` 转发事件；前端 WebSocket 客户端在 `onopen/onclose/onerror` 主动更新连接状态，并定时发送 `ping` 心跳，服务端返回 `status` 确认连接仍活跃。

### 工作区加载

1. `VideoWorkspace` 根据 `currentProject.id` 调用 `getProjectFrames()`。
2. 若无帧但项目有 `video_path`，显示“尚未生成帧”的状态提示，不自动触发 `parseMedia()`。
3. 帧数据映射为 store `Frame[]`，包含 `timestampMs` 和 `sourceFrameNumber`，供时间轴和后续视频传播使用。
4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时，会替换当前项目帧中的已保存 mask，但保留没有 `annotationId` 的未保存 draft mask；这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖，并会在合并完成后恢复仍然存在的已选 mask id。
5. `VideoWorkspace` 加载项目帧时会优先按当前选中 mask 的 `frameId` 和当前打开帧 id 恢复 `currentFrameIndex`；只有没有可恢复帧时才回到第一帧，避免 AI 页在非第一帧推送回工作区时视角被重置。
6. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区，避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态；如果切换到另一帧时原 id 不存在，但目标帧存在同一自动传播链的结果，前端会用 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 匹配对应传播 mask 并自动选中。
7. `CanvasArea` 根据容器和帧尺寸按 86% 适配比例计算初始 scale/position，使底图默认居中且尽量大，但保留画布边距；滚轮缩放和拖拽平移仍由用户后续控制。
8. `CanvasArea` 未选中特定 mask 时，会按 `classZIndex` 从低到高渲染当前帧 mask；该值来自右侧“语义分类树”的拖拽排序，因此高优先级类别会后渲染并覆盖低优先级类别。有选中 mask 时，编辑态可保留选中区域置顶，方便拖点、换类和布尔操作。
9. `FrameTimeline` 顶部播放进度条显示当前播放位置；其下方视频处理进度条根据 `Mask.metadata.source` / `propagated_from_frame_id` 计算自动传播帧并显示蓝色区段，对人工绘制或 AI 智能分割等非传播 mask 帧显示红色竖线。当前帧另用白色竖线贯穿播放进度条和视频处理进度条，和青色播放进度、红色标注、蓝色传播状态区分。普通状态下，视频处理进度条可点击跳转到对应帧，红色人工/AI 标注帧和蓝色自动传播帧标识本身也可点击跳转。处理条未处理背景使用中性灰，和红色/蓝色标记保持明显区分。`VideoWorkspace` 会记录当前会话最近 8 次成功处理过的自动传播范围，并通过 `propagationHistory` 传给 `FrameTimeline`；时间轴会把这些片段叠加为同一蓝色系的纯色条，按距最新传播的时间顺序逐次变暗，且第 5 次及更早统一为阈值旧记录色，不再在单个片段内部使用渐变。传播历史条只显示当前仍有自动传播 mask 的帧，`VideoWorkspace` 会在 mask 变化时按剩余传播 mask 裁剪本地传播历史；`FrameTimeline` 渲染时也会按当前传播 mask 再次拆分/过滤，避免单独删除传播 mask 后空帧仍显示红/蓝颜色。底部缩略图导航轴对非当前帧使用红色边框标识人工/AI 标注帧，使用蓝色边框标识自动传播/推理帧；如果同一帧同时存在人工/AI 标注和自动传播结果，红色人工/AI 标注边框优先保留，自动传播状态只作为蓝色内描边。当前帧使用青色外框高亮优先，若当前帧同时是人工/AI 标注帧，则以青色外框加红色内描边同时表达两个状态，外层当前帧框和内层人工/AI 框的顺序固定。工作区进入自动传播、布尔操作或特定范围帧导出选择模式时，播放进度条和视频处理进度条显示 amber 覆盖层，并额外用洋红色起始线和黄绿色结束线贯穿两条进度条，表达待处理或待导出范围边界，可点击/拖拽设置起止帧。
10. 当前帧传入 `CanvasArea`。
11. 工作区顶栏短状态文本会在空闲状态下自动消失；保存、导出、导入 GT 和传播任务运行中仍保留进度状态，无帧项目提示也会保留。
12. 左侧工具栏和右侧本体/语义分类面板使用 `seg-scrollbar` 定制纵向滚动条；默认滚动条 thumb 低透明度融入深色背景，hover/focus 时增强为青色提示，避免系统默认滚动条在工具区中过于突兀。左侧工具栏额外保留右侧滚动条槽位，按钮列仍按原 48px 布局，避免滚动条和图标抢空间。
12. 右侧面板不再显示“本体论与属性分类管理树”固定说明栏，直接展示实际可操作内容。
13. 右侧“遮罩透明度”滑杆写入 Zustand `maskPreviewOpacity`，`CanvasArea` 和 `AISegmentation` 都用该值计算 mask group opacity；选中 mask 在基础透明度上加亮或按基础透明度显示，方便保留选中反馈。
14. Canvas 点击 mask 后，全局 `selectedMaskIds` 会同步到 `OntologyInspector`；本体面板按选中 mask 的 `classId`、`className/label` 和颜色匹配模板分类，自动设置 active class，并把分类按钮滚动/聚焦到可见区域。
15. 工作区顶栏只在进入自动传播、传播链布尔操作或传播链清空时显示对应范围控制；自动传播由左侧工具栏按钮进入范围选择，传播权重下拉旁显示当前权重和相对参考帧的向前/向后帧数，点击“开始传播”后提交后台任务。布尔操作范围选择时，顶栏按钮变为“确认区域合并”或“确认重叠区域去除”；清空范围选择时顶栏按钮变为“确认清空”；点击后均弹出最终确认，再只对范围内存在对应传播链的帧执行。顶栏不再提供重复的“清空片段遮罩”。

### AI 点/框推理

1. 用户在 Canvas 选择正向点、反向点或框选。
2. `CanvasArea` 读取当前帧 ID 和宽高。
3. SAM 2.1 框选会创建一个候选 mask，并记录原始框；后续正向点/反向点会累计到同一候选上。
4. `predictMask()` 归一化坐标并携带当前 `model` 调用 `/api/ai/predict`；同时有框和点时发送 `interactive` prompt。
5. SAM 2.1 请求中只要存在反向点，`CanvasArea` 会额外发送 `options.auto_filter_background=true` 和 `options.min_score=0.05`，让后端移除低分结果和包含负向点的 polygon。
6. 后端加载帧图片并通过 SAM registry 分发到所选 SAM 2.1 变体；`model=sam2` 会兼容归一化为 tiny，`model=sam3` 会被拒绝。
7. 前端把 `polygons` 转为 mask；交互式细化会替换同一个候选 mask，而不是新增多个 mask。
8. 若带反向点的 SAM 2.1 细化返回空结果，前端会删除当前旧候选 mask 并提示反向点已排除该区域。
9. AI 页面只按本页最新生成的候选 id 渲染 mask，不把工作区已有 mask 带入 AI 画布；每次 `runInference()` 都先过滤掉旧 `aiMaskIds` 对应候选，再写入本次最高分候选。
10. AI 页面候选 mask 的 Path 点击事件会先判断当前工具；正向/反向选点工具下点击 mask 会继续追加提示点，其他工具下才选中 mask。
11. 工作区 SAM 提示点由 `CanvasArea` 本地 `points` 状态维护；点击已渲染提示点会先 `cancelBubble`，再删除对应点并按剩余提示重新调用 `runInference()`，避免同一次点击继续触发 Stage 加点或 Path 选择。
12. AI 页面边界框选由 `promptBox/boxStart/boxCurrent` 维护；拖拽时渲染蓝色虚线框，鼠标释放后固化 `promptBox` 并清空旧提示点，避免旧点误绑定到新框。
13. AI 页面执行分割时，如果只有 `promptBox` 则发送 `box` prompt；如果 `promptBox` 和 `points` 同时存在，`predictMask()` 会发送 interactive prompt。
14. AI 页面提示点由本地 `points` 状态维护；点击已渲染提示点会按 index 删除对应点，“删除最近锚点”会删除数组最后一个点，不改动候选 mask 列表。
15. AI 页面候选 mask 删除只接受当前 `aiMaskIds` 范围内的已选 id；“删除选中候选”和 Delete/Backspace 都复用该范围过滤，避免删除工作区已有 mask。
16. AI 页面参数开关文案只做展示增强：“局部专注模式（自动裁剪无锚区域）”仍控制 `cropMode/crop_to_prompt`，“严格除杂模式（自动清理干涉点）”仍控制 `autoDeleteBg/auto_filter_background/min_score`。
17. AI 页面“AI 遮罩透明度”滑杆复用 Zustand `maskPreviewOpacity`，和右侧“遮罩透明度”联动，只调节候选 mask 的 Konva preview opacity，不写入 `Mask.segmentation`、分类元数据或后端 payload。
18. AI 画布左上角根据正向点、反向点、边界框选和视口控制显示上下文提示，说明点击/拖拽、删除提示点和执行推理的操作方式。
19. AI 画布根据容器和当前帧尺寸按 86% 适配比例计算初始 scale/position，使底图默认居中且尽量大，但保留画布边距。
20. Canvas 按当前帧过滤并渲染 mask。
21. 新 mask 会带上当前选择的模板分类元数据，包括 `classId`、`className`、`classZIndex`、`metadata.source=ai_segmentation` 和保存状态 `draft`。
20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”；用户点击保存后，前端将像素 `segmentation` 转成 normalized `mask_data.polygons`；未保存 mask 调用 `POST /api/ai/annotate`，dirty mask 会先读取当前后端标注 id 列表，已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`，已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建；如果预检后发生并发删除导致 `PATCH` 返回 404，也会降级为重新创建，并在随后回显时排除本地旧 mask id；保存成功后本次提交的 draft mask id 会从本地保留列表中排除，并由后端 saved annotation 回显替换。
21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。
22. 工作区“清空遮罩”只从左侧工具栏触发；如果当前帧存在选中 mask，则以当前帧选中 mask 为清空对象，否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧，直接删除当前帧已保存标注并清除当前帧本地 mask，不弹确认；如果存在传播链结果，`VideoWorkspace` 弹出范围选择，用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧；按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时，如果目标帧范围包含人工/AI 标注帧，会二次询问是否删除；其中清空所有传播帧会用传播链 seed 与传播结果跨越的完整帧段检查人工/AI 帧，避免从传播结果帧触发时漏掉中间独立 AI/人工帧；选择是会删除这些人工/AI 标注帧中的全部 mask，选择否时这些帧整帧保留，只清其它自动传播帧。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认；删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id，只对存在的 id 发送 `DELETE`。

### 视频片段传播

1. 用户在工作区打开一帧作为参考帧；该帧全部 mask 都会作为传播 seed，不再提供传播对象下拉。
2. 用户点击左侧工具栏橡皮擦下方的彩色 AI 图标“自动传播”后，可以直接修改传播起始帧/结束帧数字框，并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重；该入口不提供 SAM2/SAM3 家族切换，默认跟随全局 AI 权重，用户手动选择后不再被 AI 页权重切换覆盖；未进入自动传播时顶栏不显示传播权重。
3. `VideoWorkspace` 以当前参考帧为 seed，将起止帧拆成 `backward` 和/或 `forward` 两段；只包含当前帧时不传播。
4. `VideoWorkspace` 在提交传播前会先调用现有归档保存链路保存当前项目中的 draft/dirty mask，并重新读取 store 中的回显结果；参考帧 seed 因此优先携带稳定的后端 `source_annotation_id`，避免用前端临时 mask id 生成传播结果后，二次传播无法找到旧结果。
5. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`source_mask_id` 和可用时的 `source_annotation_id`；中空 mask 会按 `metadata.polygonRingCounts` 将外圈写入 `mask_data.polygons`，把与外圈对齐的内洞写入 `mask_data.holes`，传播 seed 同步携带 `holes`；如果 seed mask 是未编辑的自动传播结果，会沿用其原始 `source_annotation_id/source_mask_id/propagation_seed_signature`，让后端把它识别为原传播链的同一个 seed；如果该传播结果被编辑并保存，更新 payload 只保留 lineage，不保留旧签名，使后端按“已修改”路径清理旧结果并重传。对历史或外部写入的 `geometry_smoothing` metadata，payload 仍可透传给后端兼容处理；当前前端平滑应用会直接改写 polygon 几何并移除该参数。
6. 前端把传播权重 id、每个 seed、每个方向组装成 `steps`，一次调用 `POST /api/ai/propagate/task`，`include_source=false`、`save_annotations=true`；接口先规范化/校验 `model` 字段中的权重 id，再创建 `processing_tasks.task_type=propagate_masks` 并投递 Celery，避免长 HTTP 请求阻塞前端等待。
7. `VideoWorkspace` 记录返回的 `task_id`，轮询 `GET /api/tasks/{task_id}` 显示任务 message、步骤进度、已处理帧次和已保存区域数；任务运行期间提供取消传播按钮，调用通用 `POST /api/tasks/{task_id}/cancel`。
8. Celery worker 逐 step 顺序执行传播，避免多个视频 tracker 并发抢占 GPU；每个 step 开始/完成都会写入 `processing_tasks.progress/result/message` 并发布 Redis `seg:progress`，Dashboard 可同步显示。每个 step 开始前，worker 会在本次目标帧段内用 seed 来源 id、传播方向和 seed 签名查找旧传播标注：同权重、签名相同且目标帧都已有结果时跳过该 seed；签名不同、目标帧只部分覆盖或本次使用了其他 SAM 2.1 权重则先删除本次目标帧段内对应方向的旧自动传播标注，再执行新的 video predictor 传播；若历史 seed 签名中包含 `geometry_smoothing`，仍按完整签名参与兼容去重。对同一参考帧多个同类别 seed，worker 以稳定来源 id/seed key 区分实例，避免 label/color/class 相同的不同实例互相清理；旧版本缺少稳定来源 id 的传播标注才使用 label/color/class 兼容匹配，写入新结果前仍用目标帧 bbox 重叠做二次确认和清理。写入前这层清理不限制旧结果方向，确保 backward 传播可覆盖早先 forward 传播留下的同物体旧 mask。
9. 后端按项目帧序列截取片段，下载对应帧到临时目录，并写成 `000000.jpg` 这类纯数字文件名；这是 `SAM2VideoPredictor` 对视频帧排序的要求，和项目库中持久化的 `frame_%06d.jpg` 对象名无关。
10. `model` 为任一 SAM 2.1 权重变体时，`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask，再用 `propagate_in_video()` 传播；注入 seed 前会把外圈 polygon 栅格化为前景，再按 `holes` 扣除内洞，避免中空参考 mask 以实心形式传播；`model=sam2` 会在入队时规范化为 tiny，任务 payload/result 会保留规范化后的权重 id；单个 SAM2 video predictor 调用内部暂不提供逐帧流式进度。
11. `model=sam3` 当前不支持；SAM 3 video tracker 代码保留但没有接入产品路径。
12. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`，跳过源帧；传播 mask 轮廓提取使用层级信息保留内洞，外圈写入 `mask_data.polygons`，内洞按外圈对齐写入 `mask_data.holes`，并设置 `metadata.hasHoles` 供前端按中空 mask 回显和编辑；如果历史或外部 seed 带 `geometry_smoothing`，保存前仍会用同一平滑参数处理 forward/backward 两个方向的结果：强度先经过缓入曲线映射，低强度使用较小 Chaikin 切角比例和简化阈值，高强度再逐步增加迭代、切角和简化力度；随后按强度对 SAM 密集轮廓做 `approxPolyDP` 去噪简化，再做 Chaikin 平滑，最后二次简化并以平滑后的 polygon 计算 bbox 后落库。当前工作区“应用边缘平滑”会在前端把同传播链对应 mask 直接改写为新的 polygon 并移除 `geometry_smoothing` 参数，因此后续传播通常按新几何本身参与 seed 签名。`mask_data.source` 记录权重传播来源，同时写入 `propagation_seed_key`、`propagation_seed_signature`、`propagation_direction`、`source_annotation_id` 和 `source_mask_id` 供后续幂等传播判断；历史 `geometry_smoothing` 仅在存在时保留用于兼容判断。
13. 前端轮询到已创建区域后刷新 `GET /api/ai/annotations` 并回显新标注；任务结束后如果后端返回 0 个新区域，工作区会明确提示没有生成新的 mask，若是未改变 seed 被跳过则提示未改变 mask 已跳过。处理过帧次大于 0 的成功任务会追加一条本地传播历史片段，用于视频处理进度条显示最近传播范围；`annotationToMask()` 会保留传播来源 metadata，供时间轴视频处理进度条显示蓝色传播区段。

### 手工绘制与历史栈

1. 用户在 `ToolsPalette` 选择多边形、矩形、圆、画笔或橡皮擦工具；创建点和创建线段入口不在工作区左侧工具栏中提供。
2. `CanvasArea` 将交互坐标转换成像素 polygon。
3. 多边形工具逐次记录节点，三点后点击首节点或按 Enter 时生成闭合 polygon。
4. Canvas 左上角根据当前工具和操作阶段显示上下文短提示；多边形提示会随已放置点数切换，明确 Enter 完成、Esc 取消和点击首节点闭合。提示会在工具或操作状态变化时出现，并在数秒后自动隐藏，避免长期遮挡底图。
5. mask path 只在 `move`、`edit_polygon`、`area_merge` 和 `area_remove` 工具下拦截点击；绘制、画笔、橡皮擦和 AI prompt 工具点击已有 mask 时继续冒泡给 Stage。
6. 画笔/橡皮擦尺寸保存在 Zustand 中；拖动期间只保留采样后的圆形笔触预览，鼠标松开后再用 `polygon-clipping` 执行 union/difference，避免拖动中反复重算复杂 polygon。
7. 新 mask 写入 `pathData`、像素 `segmentation`、`bbox`、`area` 和当前模板分类元数据。
8. `addMask()`、`setMasks()`、`updateMask()`、`clearMasks()` 会维护 `maskHistory/maskFuture`。
9. 工作区撤销/重做只保留顶栏按钮和快捷键入口，AI 页保留自己的撤销/重做按钮；工作区由 `VideoWorkspace` 在 window capture 阶段统一处理 `Ctrl/Cmd+Z`、`Ctrl/Cmd+Shift+Z` 和 `Ctrl/Cmd+Y`，快捷键判断由 `src/lib/keyboardShortcuts.ts` 同时兼容 `event.key` 与物理键码 `event.code=KeyZ/KeyY`；输入框、下拉框和可编辑文本聚焦时跳过快捷键，避免影响帧范围输入。

### Polygon 逐点编辑

1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path，`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 所有可编辑 polygon 的顶点控制点和边中点插入手柄；多 polygon 或分离区域组成的同一个 mask 不再只显示第一条 polygon。
2. 顶点 `mousedown/dragstart` 会立即设置当前顶点选择；拖动过程中通过 `dragMove` 实时重算 `pathData`、像素 `segmentation`、`bbox`、`area`，不需要先单击顶点再拖动。
3. Stage 的 `onDragEnd` 只处理 Stage 自身拖拽；polygon 顶点等子节点拖拽结束事件会被忽略，避免子节点坐标误写入 Canvas `position` 导致视口跳动。
4. 点击边中点手柄会在该边中点插入新顶点；在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。
5. 如果 mask 已有 `annotationId`，编辑会把 `saveStatus` 标成 `dirty` 且 `saved=false`。
6. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路，把更新后的 normalized polygon 写回后端。
7. 选中顶点后 Delete/Backspace 可删除顶点；前端保持 polygon 至少三点。
8. 未选中具体顶点但选中了 mask 时，Delete/Backspace 从前端 store 删除该 mask；左侧工具栏 `DEL` 按钮调用同一删除逻辑。如果包含 `annotationId`，通过工作区回调先预检后端 annotation id 再调用删除接口；删除对象属于传播链或传播 seed 时，删除范围会扩展到同链自动传播 mask，但不移除其他帧独立 AI 推理/人工 mask。
9. 普通 mask 和导入 mask 都不显示黄色 seed point，也不提供 seed point 拖动；保存 payload 仍可保留已有 `points` 数据兼容，但画布体验统一为区域选择和 polygon 顶点编辑。

### 区域合并与去除

1. 用户选择 `area_merge` 或 `area_remove` 后，点击多个当前帧 mask 组成选择集。
2. 合并/去除模式隐藏 polygon 顶点和边中点编辑手柄，并在右下角显示已选数量；少于两个 mask 时操作按钮禁用。
3. Canvas 左上角提示布尔选择顺序：第一个选中的是主区域，后续区域参与合并或扣除。
4. 布尔选择态按选择顺序区分角色：第一个选中的主区域使用黄色实线轮廓，后续参与合并/扣除的区域使用红色虚线轮廓；所有已选区域填充透明度保持一致，避免被误解为阴影模式异常。
5. `CanvasArea` 把 `Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。
6. `area_merge` 使用 union，更新第一个选中的主 mask，并从前端 store 移除后续被合并 mask；如果被移除 mask 已保存，会调用工作区传入的删除回调删除后端标注。执行前会按 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 计算可同步的传播帧；若存在其它传播帧，先弹出范围选择，让用户选择只处理当前帧、处理所有传播帧或按帧范围选择。按帧范围选择会把本次布尔操作交给 `VideoWorkspace`，复用底部时间轴范围选择和最终确认弹窗；确认后只在范围内且具备对应关系的帧上执行同一次 union，只删除该帧参与合并的次级 mask，避免把同链但未参与同步或范围外的区域整链误删。用户在顶栏范围确认前重新点击“合并选中”开始新的布尔选择时，旧的范围请求必须立即取消。
7. `area_remove` 使用 difference，从第一个选中的主 mask 中扣除后续选中 mask，扣除对象本身保留；同样会在执行前计算可同步的传播帧并弹出当前帧/所有传播帧/按帧范围选择。按帧范围选择确认后，会在范围内其它传播帧中找到对应主区域和扣除区域并执行 difference，扣除区域本身继续保留；如果 difference 产生内洞，`segmentation` 保留外圈和 hole ring，`metadata.polygonRingCounts` 记录每个 polygon 的 ring 数，渲染时使用 even-odd fill。
8. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`，已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路；同步到传播帧时保留传播来源和 lineage metadata，避免自动传播帧在时间轴上变成人工/AI 标注帧；带洞结果的面积按外圈减内洞计算；进入调整多边形时，外圈和内洞 ring 都会显示顶点和边中点插入手柄，内洞拖动、插点、保存与回显继续保持中空结构。

### GT Mask 导入

1. 工作区左侧工具栏“导入 GT Mask”选择图片文件；入口位于“重叠区域去除”之后。
2. 前端 `importGtMask()` 以 multipart form-data 调用 `POST /api/ai/import-gt-mask`，携带 `project_id` 和 `frame_id`。
3. 后端验证项目、帧、模板后使用 OpenCV 读取灰度 mask。
4. 后端按非零像素值拆分多类别标签。
5. 后端对每个类别的前景做高精度 contour 提取，每个连通域保存为一个 `Annotation`；轮廓使用未压缩链提取并以较小 `approxPolyDP` epsilon 保留细节，超过点数上限时才逐步增加简化强度或抽样。
6. `points` 字段可保存距离变换中心 seed point 供数据兼容，`mask_data.polygons` 保存 normalized polygon，`mask_data.holes` 保存与外圈对齐的内洞，`mask_data.gt_label_value` 保存原始像素类别值；导入后的 polygon 与普通 mask 走同一套拓扑锚点统计、边缘平滑、编辑和保存链路。
7. 前端重新读取项目标注并回显。
8. `annotationToMask()` 仍可把后端 `points` 转成像素坐标保存在 mask 数据中，但 Canvas 不显示 seed point，也不提供拖动；普通 polygon 若没有后端 seed point，保存逻辑可按 polygon 自动计算内部代表点写入，以保持数据兼容。

### 模板管理

1. `TemplateRegistry` 从后端读取模板。
2. 编辑态在组件本地维护分类列表。
3. 保存时调用 `createTemplate()` 或 `updateTemplate()`。
4. 后端把 `classes`、`rules` 打包进 `mapping_rules`。
5. 返回时再解包给前端。
6. 模板详情页和编辑弹窗都支持拖拽调整语义类别层级顺序；拖拽后重算 `zIndex`，保存到后端模板并刷新当前详情页，`maskId` 保持不变。所有模板都会归一化包含黑色 `maskId: 0` 的“待分类”保留类，该类固定在语义分类树最后，不参与删除和拖拽上移。编辑弹窗点击分类后只编辑分类名称，不展示或编辑旧 `category` 来源元信息。编辑弹窗中的 JSON 批量导入支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式，并兼容带前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容；导入前会先显示分类数量、maskid 分配起点和缺失颜色提示，语法或结构错误以内联错误展示，确认导入后进入编辑态，保存模板时落库。
7. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`；切换工具、切换帧或卸载 Canvas 时会清空选择。
8. `AISegmentation` 生成 mask 后会写入全局 `masks` 并把生成的 mask id 写入 `selectedMaskIds`；点击 AI 页预览 mask 也会更新 `selectedMaskIds`。
9. AI 页“推送至工作区编辑”会先检查待推送 AI 候选 mask 是否具备 `classId` 或 `className`；缺少语义分类时清空普通推理反馈，并通过 `TransientNotice` 右上角 error toast 提示用户先点右侧语义分类树，不切换模块、不修改工具状态。
10. `AISegmentation` 卸载时会清理仍缺少 `classId/className` 的本页 AI 候选，并同步移除对应 `selectedMaskIds`，避免用户绕过推送按钮从侧栏切到工作区时带入无语义 mask。
11. AI 页语义校验通过后会切换到工作区并把 `activeTool` 设为 `edit_polygon`；`CanvasArea` 初始读取全局 `selectedMaskIds`，让 AI 页选中的 mask 在工作区继续保持选中。
12. 工作区帧/标注异步加载完成后，`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask，不会用后端回显结果直接覆盖整个 `masks` store。
13. `OntologyInspector` 可以选择激活模板和具体分类；项目已有任意 mask 时，用户修改激活模板会先弹出确认框，确认后调用删除标注接口清空当前项目所有已保存标注并清空本地 mask，再切换模板；项目没有任何 mask 时直接切换。具体分类选择结果进入全局 store，供 `CanvasArea` 和 `AISegmentation` 新建/更新 mask 时使用。
14. 如果 `selectedMaskIds` 中存在当前 store 的 mask，点击分类时会立即更新这些 mask 的 `templateId`、`classId`、`className`、`classZIndex`、`label` 和 `color`。
15. 对属于自动传播链的 mask，分类更新会复用 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 查找同一目标实例在前后帧中的传播结果，并同步更新这些传播 mask 的分类元数据，避免同一物体跨帧语义不一致。
16. 同一次点击会把这些已选 mask 移动到前端 `masks` 数组末尾；`CanvasArea` 按数组顺序渲染，后渲染的 Path 显示在最上层，方便用户继续编辑刚换标签的区域。该显示置顶不改变模板 `zIndex` 或后端导出语义覆盖规则。
17. 已保存 mask 被重新分类后进入 `dirty` 且 `saved=false`，同传播链被同步更新的已保存 mask 也进入 `dirty`，继续复用工作区归档保存的 PATCH 链路。
18. 模板保存、删除和 JSON 导入失败使用 `TransientNotice` 非阻塞提示，默认数秒后自动消失。

### 导出

1. 后端根据项目、帧、标注和模板生成 COCO JSON。
2. PNG mask 导出会把 normalized polygon 渲染为单标注二值 mask。
3. PNG mask 导出还会按 `mask_data.class.zIndex` 或模板 `z_index` 从低到高覆盖，生成每帧语义融合 mask。
4. ZIP 内写入 `semantic_classes.json`，记录语义值到类别、颜色和 zIndex 的映射。
5. 前端使用“分割结果导出”统一入口替代原 JSON/PNG 两个按钮；点击后在下拉栏选择整体视频、特定范围帧或当前图片，默认选中当前图片，并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择“特定范围帧”时，导出起止帧输入框和 `FrameTimeline` 的范围拖拽选择共用同一组导出范围状态；选择 Mix_label 时显示透明度滑杆，默认 0.3，并用当前/待导出第一帧做遮罩预览。提交前会保存待归档标注，然后下载统一 ZIP。下载文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`；项目名来自 `currentProject.name`，起止帧按当前导出范围取首尾帧，时间戳格式为 `0h00m00s000ms`，帧号使用项目抽帧后的 1-based 顺序，项目名中的文件系统不安全字符会替换为 `_`。
6. 统一导出 ZIP 固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`；原始图片文件名使用 `视频名称_时间戳_项目帧序号`。导出会保留类别真实 maskid，GT_label 固定为 8-bit uint8 PNG，像素值与 maskid 相同并跨图一致；`maskId: 0` 的“待分类”保持 0，和背景同为黑色，Pro_label 中也输出为 `[0,0,0]`；缺失 maskid 的旧标注才补下一个可用正整数并写入映射 JSON；正整数 maskid 超出 1-255 会拒绝导出。选择分开 mask 时包含 `分开Mask分割结果/`，每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹，并按“同一帧同一类别合并一张图”的方式输出 `{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`。选择 GT_label 图时包含 `GT_label图/{视频名称_时间戳_项目帧序号}.png`；选择 Pro_label 图时包含 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`；选择 Mix_label 图时包含 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。GT_label、Pro_label 和 Mix_label 的重叠区域按内部拖拽排序从低到高覆盖，和未选中状态下的画布显示顺序一致；maskid 不参与排序。后端直接下载接口的 `Content-Disposition` 使用同一 ZIP 命名规则，并用 `filename*` 支持中文项目名。
7. 右侧 `OntologyInspector` 的语义分类树支持拖拽调整内部覆盖顺序；拖拽后保存到模板并同步当前工作区同类 mask 的 `classZIndex`，但保留类别 maskid 不变。

## 接口契约

接口详情见 `doc/04-api-contracts.md`。测试中重点固定以下契约：

- `updateProject()` 使用 `PATCH /api/projects/{id}`。
- `exportCoco()` 使用 `GET /api/export/{projectId}/coco`。
- `exportMasks()` 使用 `GET /api/export/{projectId}/masks`。
- `exportSegmentationResults()` 使用 `GET /api/export/{projectId}/results`，通过 query 参数选择范围和 mask 类型。
- `cancelTask()` 使用 `POST /api/tasks/{taskId}/cancel`。
- `retryTask()` 使用 `POST /api/tasks/{taskId}/retry`。
- `predictMask()` 使用 `POST /api/ai/predict`，请求体为 `image_id`、`prompt_type`、`prompt_data`、`model`。
- `propagateMasks()` 使用 `POST /api/ai/propagate`，请求体为 `project_id`、`frame_id`、`model`、`seed`、`direction`、`max_frames`，作为单 seed 同步兼容接口保留。
- `queuePropagationTask()` 使用 `POST /api/ai/propagate/task`，请求体为 `project_id`、`frame_id`、`model`、`steps`、`include_source`、`save_annotations`，返回 `ProcessingTask`。
- `saveAnnotation()` 使用 `POST /api/ai/annotate`。
- `importGtMask()` 使用 `POST /api/ai/import-gt-mask` multipart form-data，并传入 `unknown_color_policy=discard|undefined`。前端上传前弹出导入结果预览和未知 maskid 策略选择；后端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype。合法 GT mask 限定为 8-bit 灰度图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图，0 为背景、X 为 1-255 的 maskid；灰度/RGB 等通道图按模板 `maskId` 匹配类别，16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图不再按颜色匹配并会返回格式错误；全背景图提示为“GT Mask 图片中没有非背景 maskid 区域。”；未知类别按策略舍弃或保存为 `gt_unknown_class` 未定义类别。若 GT mask 尺寸和当前帧不同，后端用最近邻插值拉伸到当前帧尺寸后再生成高精度 polygon。
- `getProjectAnnotations()` 使用 `GET /api/ai/annotations`。
- `updateAnnotation()` 使用 `PATCH /api/ai/annotations/{annotationId}`。
- `deleteAnnotation()` 使用 `DELETE /api/ai/annotations/{annotationId}`；工作区批量删除前会先用 `getProjectAnnotations()` 预检当前项目存在的 id，跳过本地陈旧 id，避免已被撤销/清空流程删除过的 annotation 再次发起 DELETE 产生 404。
- `parseMedia()` 使用 `POST /api/media/parse?project_id=...`，可选 `parse_fps`、`max_frames`、`target_width`，用于生成标准帧序列。
- `getProjectFrames()` 返回帧图像 URL、宽高、`timestamp_ms` 和 `source_frame_number`。
- 后端 `/api/ai/predict` 当前支持 SAM 2.1 的 point、box、interactive；`semantic` 文本提示禁用并返回 400。
- SAM 2.1 是点/框交互式分割模型，不做文本语义分割；AI 页面已经移除纯文本输入。
- SAM 2.1 点提示和 auto fallback 只返回一个最高分候选，避免同一提示产生多个重叠候选 mask。
- SAM 3 前端入口、后端 registry 入口和状态展示均已禁用；`model=sam3` 会返回不支持。
- 后端 `/api/ai/predict` 支持可选 `options`：`crop_to_prompt` 会对 point/box/interactive prompt 做局部裁剪推理并回映射 polygon，`auto_filter_background` 会按 `min_score` 和负向点过滤结果。
- 后端 `/api/ai/propagate/task` 当前支持所选 SAM 2.1 mask seed 视频传播后台任务；同步 `/api/ai/propagate` 仍保留为单 seed 兼容接口。
- 后端 `/api/ai/models/status` 返回 GPU 和四个 SAM 2.1 变体的真实运行状态。
- point prompt 支持旧数组形式和 `{ points, labels }` 对象形式。

## 外部依赖边界

测试不直接依赖以下真实服务：

- PostgreSQL：后端测试使用内存 SQLite。
- MinIO：上传、下载、预签名 URL 使用 monkeypatch。
- Redis：单测使用 monkeypatch 验证进度事件发布，不依赖真实 Redis 服务。
- SAM：AI 推理测试使用 fake registry。
- 浏览器 Canvas/Konva 图片加载：前端测试 mock `react-konva` 和 `use-image`。

## 已知占位设计

以下能力属于当前冻结版本的占位或半可用功能：

- Dashboard 初始快照来自 `GET /api/dashboard/overview`；任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成，处理中统计只计算 queued/running。
- 已保存标注支持通过右侧语义分类树换标签、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新；多 polygon/分离区域选中后所有子区域都显示编辑手柄，同帧同传播链的分散 mask 会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮；旧传播结果缺少稳定 lineage 时，会用传播来源、来源帧、方向、分类/标签/颜色构造兼容分组，保证同一传播 mask 拆出的不连通片段仍一起高亮；Canvas 右下角不再提供旧的“应用分类”按钮，避免没选区时误改整帧；区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择，范围选择复用时间轴和确认弹窗，并保留传播帧来源 metadata；选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除，同步后端前会预检 id，同传播链自动传播结果会随传播 seed/传播结果删除而一并清理，独立 AI 推理/人工 mask 保留。
- SAM 3 文本语义分割已从当前产品路径中禁用；相关源码保留，恢复时需要重新接入前端入口、registry、状态接口和测试。
- 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`。
- 选中 mask 后，本体面板的“特定目标实例属性追踪”标题值来自当前 mask 的 `className/label`，不使用全局 active class；面板不再展示长期为 1 的“当前选中区域”计数；面板调用 `POST /api/ai/analyze-mask` 自动显示拓扑锚点数量等属性，`topology_anchor_count` 是真实 polygon 顶点数量，`topology_anchors` 只保留最多 64 个抽样点用于调试展示；`OntologyInspector` 会为分析请求维护递增序号，旧请求返回时不再回写状态，并静默忽略 Axios abort/cancel 错误，避免快速切换、平滑预览或组件卸载时把正常中止误报成失败；不再提供“重新提取拓扑锚点”调试按钮；“边缘平滑强度”滑杆会即时更新数值，但 `POST /api/ai/smooth-mask` 预览请求经过约 220ms 防抖后才发送，返回 polygon 作为临时预览写入当前 mask 显示，预览不改变保存状态；点击“应用边缘平滑”后，前端把平滑 polygon 作为新的实际几何写入当前 mask，并按传播 lineage 同步写入传播链前后对应 mask，相关 mask 标记为 dirty/draft，整次操作通过一次 `setMasks()` 进入撤销/重做历史；应用后不保留 `geometry_smoothing` 参数，平滑强度重置为 0。前端不再展示“后端模型置信度”。
- GT mask 导入已完成多类别像素值拆分、contour 和 distance transform seed point 数据兼容；前端不显示或拖动 seed point，导入 mask 与普通 mask 共享拓扑统计、边缘平滑、顶点编辑、分类和保存体验；骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。
