# 接口契约清单

## 前端 API 基础配置

位置：`src/lib/config.ts`、`src/lib/api.ts`

```ts
API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://<current-browser-host>:8000'
timeout: 30000
```

前端 request interceptor 会从 localStorage 读取 `token`，附加：

```http
Authorization: Bearer <token>
```

当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401；项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库，所有登录用户可读取，`admin/annotator` 可写入。

## 前端封装的 API

| 函数 | 方法与路径 | 状态 | 说明 |
|------|------------|------|------|
| `login(username, password)` | `POST /api/auth/login` | 对齐 | 后端返回 `{ token, token_type, username, user }`，前端保存 token 和当前用户 |
| `getCurrentUser()` | `GET /api/auth/me` | 对齐 | 用已有 Bearer token 恢复当前登录用户 |
| `getProjects()` | `GET /api/projects` | 对齐 | 前端映射 `frame_count`、`thumbnail_url` 等字段 |
| `createProject(payload)` | `POST /api/projects` | 对齐 | 支持 `name`、`description`、`parse_fps` |
| `updateProject(id, payload)` | `PATCH /api/projects/{id}` | 对齐 | 后端是 `PATCH /api/projects/{id}` |
| `deleteProject(id)` | `DELETE /api/projects/{id}` | 对齐 | 项目卡片删除按钮已接入，删除前使用站内确认弹窗 |
| `getTemplates()` | `GET /api/templates` | 对齐 | 前端从 `mapping_rules` 取 classes/rules |
| `createTemplate(payload)` | `POST /api/templates` | 对齐 | 后端会打包 classes/rules 到 mapping_rules |
| `updateTemplate(id, payload)` | `PATCH /api/templates/{id}` | 对齐 | 模板编辑页使用 |
| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 |
| `uploadMedia(file, projectId, options?)` | `POST /api/media/upload` | 对齐 | multipart form-data；`options.onProgress` 用于项目库上传进度 |
| `uploadDicomBatch(files, projectId, options?)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data；`options.onProgress` 用于项目库上传进度，上传完成后项目库轮询解析任务进度 |
| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task；由项目库“生成帧”显式调用，支持 `parse_fps`、`max_frames`、`target_width` |
| `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 |
| `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务，后端写 cancelled 并尝试 revoke Celery |
| `retryTask(taskId)` | `POST /api/tasks/{task_id}/retry` | 对齐 | 对 failed/cancelled 任务创建新的 queued 重试任务 |
| `getProjectFrames(projectId)` | `GET /api/projects/{id}/frames` | 对齐 | 后端返回预签名 image_url，以及 `timestamp_ms`、`source_frame_number` |
| `predictMask(payload)` | `POST /api/ai/predict` | 对齐 | 前端发送 `image_id/prompt_type/prompt_data/model`，并把后端 `polygons` 转为 `masks[].pathData` |
| `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 单 seed 同步传播接口，供后端兼容和测试使用 |
| `queuePropagationTask(payload)` | `POST /api/ai/propagate/task` | 对齐 | 工作区“AI自动推理”入口；创建 Celery 后台任务并由任务表/进度流追踪 |
| `getAiModelStatus(selectedModel?)` | `GET /api/ai/models/status` | 对齐 | 返回 GPU 和四个 SAM 2.1 变体状态；`selected_model=sam3` 返回不支持 |
| `analyzeMask(mask, frame, options?)` | `POST /api/ai/analyze-mask` | 对齐 | 后端计算选中 mask 的置信度来源、拓扑锚点数量、面积和 bbox |
| `getProjectAnnotations(projectId, frameId?)` | `GET /api/ai/annotations` | 对齐 | 前端加载工作区时用于回显已保存标注 |
| `saveAnnotation(payload)` | `POST /api/ai/annotate` | 对齐 | 工作区归档保存当前项目未保存 mask |
| `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask；保存链路会先预检后端标注 id，已知缺失则直接用同一几何和 metadata 调用 `saveAnnotation()` 重新创建；预检后仍遇到 404 时也会重新创建并回显替换本地旧 id |
| `deleteAnnotation(annotationId)` | `DELETE /api/ai/annotations/{annotation_id}` | 对齐 | 工作区清空当前帧、关联传播帧、DEL/键盘删除和切换激活模板时删除已保存标注；批量删除前会先读取当前项目 annotation 列表，跳过本地陈旧 id，避免重复 DELETE 产生 404 |
| `importGtMask(file, projectId, frameId, templateId?, options?)` | `POST /api/ai/import-gt-mask` | 对齐 | multipart 上传 GT mask；支持 `unknown_color_policy=discard/undefined`；后端仅接受 8-bit 灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图，0 为背景、X 为 1-255 的 maskid；16-bit/uint16 GT_label、全背景 0 图和普通彩色类别图会被拒绝，全背景错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”；按模板 `maskId` 匹配类别，未知 maskid 可舍弃或导入为黑色 `maskid:0` 的“待分类”；尺寸不同会最近邻拉伸到当前帧，连通域会生成高精度 polygon 标注；导入标注可直接用于 `/api/ai/analyze-mask` 和 `/api/ai/smooth-mask`，前端不显示或拖动 seed point |
| `getDashboardOverview()` | `GET /api/dashboard/overview` | 对齐 | Dashboard 初始统计、队列和活动日志 |
| `exportCoco(projectId)` | `GET /api/export/{projectId}/coco` | 对齐 | 后端实际是 `GET /api/export/{project_id}/coco` |
| `exportMasks(projectId)` | `GET /api/export/{projectId}/masks` | 对齐 | 下载单标注 mask、语义融合 mask 和类别映射 ZIP |
| `exportSegmentationResults(projectId, options)` | `GET /api/export/{projectId}/results` | 对齐 | 新的统一导出入口；支持 `scope=all/range/current`、`outputs=separate,gt_label,pro_label,mix_label`、`mix_opacity`、`start_frame/end_frame` 和 `frame_id` 参数，返回包含 COCO JSON、maskid/GT 像素值映射、原始帧图片和所选 mask PNG 的 ZIP；`mask_type=separate/gt_label/pro_label/mix_label/both` 仍兼容 |

## 后端 FastAPI 接口

以下列表来自当前运行的 OpenAPI：

| 方法 | 路径 | 用途 |
|------|------|------|
| POST | `/api/auth/login` | 登录 |
| GET | `/api/auth/me` | 当前用户 |
| GET/POST/PATCH/DELETE | `/api/admin/users` | 管理员用户管理 |
| GET | `/api/admin/audit-logs` | 管理员审计日志 |
| POST | `/api/admin/demo-factory-reset` | 演示部署恢复出厂设置；请求体需 `confirmation=RESET_DEMO_FACTORY`；重置后保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目；同时按内置权威定义重建缺失的“腹腔镜胆囊切除术”“头颈部CT分割”系统模板，并覆盖恢复被修改或删减的默认语义分类树；响应包含兼容单个 `project` 和完整 `projects` 列表 |
| POST | `/api/projects` | 创建项目 |
| GET | `/api/projects` | 项目列表 |
| GET | `/api/projects/{project_id}` | 项目详情 |
| PATCH | `/api/projects/{project_id}` | 更新项目 |
| DELETE | `/api/projects/{project_id}` | 删除项目 |
| POST | `/api/projects/{project_id}/frames` | 添加帧记录 |
| GET | `/api/projects/{project_id}/frames` | 项目帧列表 |
| GET | `/api/projects/{project_id}/frames/{frame_id}` | 单帧详情 |
| POST | `/api/templates` | 创建模板 |
| GET | `/api/templates` | 模板列表 |
| GET | `/api/templates/{template_id}` | 模板详情 |
| PATCH | `/api/templates/{template_id}` | 更新模板 |
| DELETE | `/api/templates/{template_id}` | 删除模板 |
| POST | `/api/media/upload` | 上传视频/图片/DICOM 单文件 |
| POST | `/api/media/upload/dicom` | 批量上传 DICOM |
| POST | `/api/media/parse` | 创建 Celery 拆帧任务；query 支持 `project_id`、`source_type`、`parse_fps`、`max_frames`、`target_width` |
| GET | `/api/tasks` | 查询后台任务列表 |
| GET | `/api/tasks/{task_id}` | 查询单个后台任务 |
| POST | `/api/tasks/{task_id}/cancel` | 取消后台任务 |
| POST | `/api/tasks/{task_id}/retry` | 重试失败或取消的后台任务 |
| POST | `/api/ai/predict` | 当前启用 SAM 2 点/框/interactive 推理 |
| POST | `/api/ai/propagate` | 当前启用 SAM 2 单 seed 同步视频片段传播并保存标注 |
| POST | `/api/ai/propagate/task` | 创建 SAM 2 自动传播后台任务；payload 可包含多个 seed/direction step |
| POST | `/api/ai/analyze-mask` | 分析前端选中 mask 的后端几何属性和拓扑锚点 |
| GET | `/api/ai/models/status` | GPU 和 SAM 模型状态 |
| POST | `/api/ai/auto` | 自动分割 |
| POST | `/api/ai/annotate` | 保存 AI 标注 |
| POST | `/api/ai/import-gt-mask` | 导入 GT mask 并生成标注/seed point |
| GET | `/api/ai/annotations` | 查询项目标注，可选按帧过滤 |
| PATCH | `/api/ai/annotations/{annotation_id}` | 更新已保存标注 |
| DELETE | `/api/ai/annotations/{annotation_id}` | 删除已保存标注 |
| GET | `/api/dashboard/overview` | Dashboard 聚合快照 |
| GET | `/api/export/{project_id}/coco` | 导出 COCO JSON |
| GET | `/api/export/{project_id}/masks` | 导出 PNG mask ZIP |
| GET | `/api/export/{project_id}/results` | 统一导出分割结果 ZIP，包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json`、`原始图片/` 和按参数选择的 `分开Mask分割结果/`、`GT_label图/`、`Pro_label彩色分割结果/`、`Mix_label重叠覆盖彩色分割结果/`；GT_label 固定输出 8-bit uint8 PNG，背景为 0，类别值使用模板中的真实 maskid，`maskid:0` 待分类和背景同为 0，缺失 maskid 的旧标注才补下一个可用正整数；正整数 maskid 超出 1-255 时拒绝导出 |
| GET | `/health` | 健康检查 |
| WS | `/ws/progress` | WebSocket 进度通道，未出现在 OpenAPI paths 中 |

### WebSocket 进度通道

`/ws/progress` 用于 Dashboard 实时接收后台任务状态。前端连接成功后会定时发送 `ping` 作为心跳；后端收到任意文本心跳后返回：

```json
{
  "type": "status",
  "status": "connected",
  "message": "Progress stream active",
  "timestamp": "2026-05-01T00:00:00+00:00"
}
```

后台任务进度由 Celery worker 写入 Redis `seg:progress` 频道，再由 FastAPI 转发到当前活跃 WebSocket 连接。Dashboard 的“WebSocket 已连接/断开”状态来自浏览器 WebSocket 的 `onopen/onclose/onerror`，不再依赖是否刚好收到任务进度事件。

## 关键请求体

### 登录

```json
{
  "username": "admin",
  "password": "123456"
}
```

### 创建项目

```json
{
  "name": "example.mp4",
  "description": "导入说明",
  "parse_fps": 30
}
```

### 创建标准帧序列拆帧任务

```text
POST /api/media/parse?project_id=1&parse_fps=15&max_frames=120&target_width=960
```

任务 `payload` 会记录本次拆帧参数；完成后的 `result.frame_sequence` 返回 `original_fps`、`parse_fps`、`frame_count`、`duration_ms`、`target_width`、帧宽高和 MinIO object prefix。每条 `FrameOut` 包含：

```json
{
  "frame_index": 0,
  "image_url": "http://...",
  "width": 960,
  "height": 540,
  "timestamp_ms": 0,
  "source_frame_number": 0
}
```

### 创建/更新模板

```json
{
  "name": "腹腔镜胆囊切除术",
  "color": "#06b6d4",
  "z_index": 0,
  "classes": [
    {
      "id": "cls-1",
      "name": "胆囊",
      "color": "#ffae00",
      "zIndex": 280,
      "maskId": 1,
      "category": "腹腔镜胆囊切除术"
    }
  ],
  "rules": []
}
```

### AI 推理请求体

前端 `predictMask()` 当前已适配后端 `PredictRequest`：

```json
{
  "image_id": 123,
  "model": "sam2.1_hiera_tiny",
  "prompt_type": "point",
  "prompt_data": {
    "points": [[0.5, 0.5]],
    "labels": [1]
  }
}
```

`prompt_type` 支持：

- `point`
- `box`
- `interactive`，用于 SAM 2 交互式细化，`prompt_data` 同时携带 `box`、累计 `points` 和 `labels`。
- `semantic` 当前被禁用；由于产品不提供文本提示，前端不会显示语义文本入口，后端收到 semantic 会返回 400。

SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask，避免同一提示下多个备选 mask 被前端叠加显示。

工作区 SAM 2 请求包含反向点时，`CanvasArea` 会发送 `options.auto_filter_background=true` 和 `options.min_score=0.05`；如果负向点过滤后没有可用 polygon，前端会移除当前旧候选 mask 并要求重新框选或添加正向点。

当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`，并兼容 `sam2` 作为 tiny 别名；发送 `model=sam3` 会返回 400 Unsupported model。SAM 3 源码文件保留在仓库中，但没有接入当前运行时模型列表。

可选 `options` 字段：

- `crop_to_prompt`：对 point/box/interactive prompt 按锚点或框附近区域裁剪后推理，再把 polygon 回映射到原图坐标。
- `auto_filter_background`：过滤低分结果，并移除包含负向点的 polygon。
- `min_score`：配合 `auto_filter_background` 使用的最低置信度阈值。

后端响应：

```json
{
  "polygons": [
    [[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.75]]
  ],
  "scores": [0.5]
}
```

前端会把上面的 `polygons` 转成：

```json
{
  "masks": [
    {
      "pathData": "M 160 90 L 480 90 L 480 270 L 160 270 Z",
      "segmentation": [[160, 90, 480, 90, 480, 270, 160, 270]],
      "bbox": [160, 90, 320, 180]
    }
  ]
}
```

### 视频片段传播请求体

`POST /api/ai/propagate` 仍是单 seed 同步接口。工作区实际使用 `POST /api/ai/propagate/task`：当前打开帧作为参考帧，该帧全部 mask 作为 seed；用户设置传播起始帧和传播结束帧后，前端会在本地把多个 seed 或前后双向范围拆成 `steps`，一次提交为 `propagate_masks` 后台任务，避免长 HTTP 请求和多个视频 tracker 并发抢占 GPU。

单次调用示例：

```json
{
  "project_id": 1,
  "frame_id": 123,
  "model": "sam2.1_hiera_tiny",
  "direction": "forward",
  "max_frames": 30,
  "include_source": false,
  "save_annotations": true,
  "seed": {
    "polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
    "bbox": [0.1, 0.1, 0.2, 0.2],
    "label": "胆囊",
    "color": "#ff0000",
    "class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20, "maskId": 1},
    "template_id": 2,
    "source_instance_id": "instance-123"
  }
}
```

后台任务调用示例：

```json
{
  "project_id": 1,
  "frame_id": 123,
  "model": "sam2.1_hiera_tiny",
  "include_source": false,
  "save_annotations": true,
  "steps": [
    {
      "direction": "forward",
      "max_frames": 30,
      "seed": {
        "polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
        "label": "胆囊",
        "color": "#ff0000",
        "source_instance_id": "instance-123"
      }
    }
  ]
}
```

SAM 2.1 变体使用对应 video predictor 的 mask seed 传播；`model=sam2` 会兼容归一化为 tiny，`model=sam3` 当前不支持。响应会返回已创建的 `annotations`，保存的 `mask_data.source` 为 `<model_id>_propagation`，前端回显时会把该字段保留到 `Mask.metadata`，用于在视频处理进度条上把自动传播帧显示为蓝色区段。
后台任务入队接口会先规范化/校验 `model` 字段中的 SAM 2.1 权重 id，再把规范化后的权重 id 写入 `processing_tasks.payload.model`；前端提交传播前会先保存当前项目中的 draft/dirty mask，使 seed 尽量携带稳定的 `source_instance_id`、`source_annotation_id` 和 `source_mask_id`。如果参考 mask 本身来自自动传播且未被编辑，前端会继承其 `source_instance_id` 和 `propagation_seed_signature`，让后端识别它仍是原始 seed 的同一条传播链；如果该 mask 被编辑，保存时只保留 lineage，不继承旧签名，从而触发旧结果清理和重传。worker 保存传播结果时会写入 `instance_id`、`source_instance_id`、`propagation_seed_key`、`propagation_seed_signature` 和 `propagation_direction`。同一目标帧段内，同一 seed、同一权重、同一方向再次传播时，如果所有目标帧已有同签名结果，worker 会跳过该 seed；如果签名变化、目标帧段只部分覆盖或本次改用其他 SAM 2.1 权重，worker 会先删除本次目标帧段内的旧自动传播标注再保存新结果。同一参考帧多个同类别 seed 会优先按 `source_instance_id/instance_id` 区分实例，再兼容 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key`，避免 label/color/class/maskid 相同的不同 mask 互相清理；旧版本缺少稳定来源 id 的传播结果才走 label/color/class 兼容清理，避免保存后的稳定 id 无法替换旧结果。任务运行中/完成后会写入 `processing_tasks.result.model`、`completed_steps`、`processed_frame_count`、`created_annotation_count`、`deleted_annotation_count`、`skipped_seed_count` 和每个 step 的权重/方向/数量结果；前端通过 `GET /api/tasks/{task_id}` 轮询，Dashboard 同时可通过 Redis/WebSocket 进度流显示该任务。

## 已完成的接口对齐

- `updateProject()` 已从 `PUT` 改为 `PATCH`。
- `exportCoco()` 已从 `/api/export/coco/{projectId}` 改为 `/api/export/{projectId}/coco`。
- Canvas 已使用真实 `frame.id` 作为 `image_id`。
- 点和框坐标已转成后端需要的归一化坐标。
- 后端 `polygons` 已在前端转成 Konva 可渲染的 path。
- `saveAnnotation()` 已接入 `POST /api/ai/annotate`。
- `getProjectAnnotations()` 已接入 `GET /api/ai/annotations`。
- `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}`。
- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`；工作区批量删除前会先用 `GET /api/ai/annotations` 预检存在的 id，跳过本地陈旧 id。
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`，导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype，仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图，并按模板 `maskId` 匹配类别；16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误，全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示；超出现有类别时由 `unknown_color_policy` 决定舍弃或写为黑色 `maskid:0` 的“待分类”，并保留 `gt_unknown_class` 和原始 `gt_label_value`。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口，中空导入结果通过 `mask_data.holes` 和 `metadata.polygonRingCounts` 回显为可编辑内洞，前端不显示黄色 seed point。
- `exportMasks()` 已接入 `GET /api/export/{projectId}/masks`。
- `parseMedia()` 已改为创建 Celery 后台任务，并返回 `ProcessingTask`。
- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`，自动传播不再依赖长时间同步 HTTP 请求；传播 seed 可携带与 `polygons` 对齐的 `holes` 和 `source_instance_id`，后端 seed 签名、SAM 2 seed mask 栅格化和传播结果保存都会保留内洞，并用实例 id 区分同语义多 mask。
- `getTask()` 已接入 `GET /api/tasks/{taskId}`。
- `cancelTask()` 已接入 `POST /api/tasks/{taskId}/cancel`。
- `retryTask()` 已接入 `POST /api/tasks/{taskId}/retry`。
- `getDashboardOverview()` 已从 `processing_tasks` 聚合解析队列。
- Dashboard 任务列表已展示 queued/running/success/failed/cancelled 任务，并可通过 `getTask()` 查看失败详情；`summary.parsing_task_count` 仍只统计 queued/running。
- 工作区“分割结果导出”已调用 `exportSegmentationResults()`，并会先保存未归档 mask；旧的 `exportCoco()` / `exportMasks()` 仍保留为兼容接口。
- PNG mask ZIP 已包含每帧 `semantic_frame_*.png` 和 `semantic_classes.json`，重叠区域按 zIndex 裁决。
- 统一导出 ZIP 下载文件名为 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`；项目名来自 `Project.name` 并会替换文件系统不安全字符，时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`，帧号使用项目抽帧后的 1-based `frame_index + 1`，不使用原视频 `source_frame_number`。ZIP 内包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`。原始图片按 `视频名称_时间戳_项目帧序号` 命名；选择分开 mask 时写入 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`，同一帧同一类别会合并为一张二值 mask；选择 GT_label 图时写入 `GT_label图/{视频名称_时间戳_项目帧序号}.png`，固定为 8-bit uint8 PNG；选择 Pro_label 彩色图时写入 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`；选择 Mix_label 叠加图时写入 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`，透明度由 `mix_opacity` 控制，默认 0.3。导出时 maskid 与 GT_label 像素值相同；有模板 maskid 的类别保留真实 maskid，其中 `maskid:0` 的“待分类”和背景同为 0，缺失 maskid 的旧标注补下一个可用正整数并写入映射 JSON，跨图一致；正整数 maskid 必须在 1-255 内，超出时拒绝导出；maskid 不参与覆盖排序，覆盖顺序仍使用内部拖拽排序字段。

## 仍需处理的接口问题

- WebSocket 地址已从 `VITE_WS_PROGRESS_URL` 读取，未配置时从 `API_BASE_URL` 推导；部署时仍要确认浏览器能访问该地址。
- Celery worker 进度会写 PostgreSQL 任务表，同时发布到 Redis `seg:progress`；FastAPI 订阅后广播到 `/ws/progress`。
- 已保存标注目前支持分类级更新、polygon 顶点拖动、顶点删除、边中点插入、多 polygon 子区域选择、中空 mask 内洞 ring 编辑后的 PATCH 更新和整帧清空删除；`mask_data.polygons` 保存外圈，`mask_data.holes` 保存与外圈对齐的内洞，`metadata.polygonRingCounts` 支撑前端把外圈/内洞重新组合成可编辑结构。
