修正GT未知类别导入为待分类

- GT Mask 未知 maskid 选择保留时落到黑色 maskid:0 的待分类类别,而不是绿色未定义类别。

- 前端导入预览中未知 maskid 使用黑色待分类覆盖色,并把按钮文案改为导入为待分类。

- 待分类兜底颜色统一为 #000000,和模板保留类、GT_label/Pro_label 导出规则一致。

- 补充后端回归断言并更新 AGENTS 与文档说明,保留 gt_unknown_class 和原始 gt_label_value 供后续重命名追溯。
This commit is contained in:
2026-05-04 06:06:04 +08:00
parent 5d73eacefe
commit 10fe17476d
11 changed files with 47 additions and 28 deletions

File diff suppressed because one or more lines are too long

View File

@@ -43,6 +43,14 @@ GT_MASK_EMPTY_DETAIL = "GT Mask 图片中没有非背景 maskid 区域。"
GT_IMPORT_MAX_CONTOUR_POINTS = 2048
GT_IMPORT_CONTOUR_EPSILON_RATIO = 0.00075
GT_IMPORT_MIN_CONTOUR_EPSILON = 0.35
RESERVED_UNCLASSIFIED_CLASS = {
"id": "reserved-unclassified",
"name": "待分类",
"color": "#000000",
"zIndex": 0,
"maskId": 0,
"category": "系统保留",
}
def _shared_project_or_404(project_id: int, db: Session, current_user: User) -> Project:
@@ -107,10 +115,11 @@ def _rgb_tuple_to_hex(rgb: tuple[int, int, int]) -> str:
return f"#{values[0]:02x}{values[1]:02x}{values[2]:02x}"
def _template_class_maps(template: Template | None) -> tuple[dict[int, dict[str, Any]], dict[str, dict[str, Any]]]:
def _template_class_maps(template: Template | None) -> tuple[dict[int, dict[str, Any]], dict[str, dict[str, Any]], dict[str, Any]]:
classes = ((template.mapping_rules or {}).get("classes") if template else None) or []
by_maskid: dict[int, dict[str, Any]] = {}
by_color: dict[str, dict[str, Any]] = {}
unclassified = dict(RESERVED_UNCLASSIFIED_CLASS)
for index, item in enumerate(classes):
if not isinstance(item, dict):
continue
@@ -128,16 +137,13 @@ def _template_class_maps(template: Template | None) -> tuple[dict[int, dict[str,
"maskId": maskid,
**({"category": item.get("category")} if item.get("category") else {}),
}
if maskid == 0 or class_meta["id"] == RESERVED_UNCLASSIFIED_CLASS["id"] or class_meta["name"] == RESERVED_UNCLASSIFIED_CLASS["name"]:
unclassified = dict(RESERVED_UNCLASSIFIED_CLASS)
continue
if maskid > 0:
by_maskid[maskid] = class_meta
by_color[color] = class_meta
return by_maskid, by_color
def _gt_unknown_label(token: int | str) -> str:
if isinstance(token, int):
return f"未定义类别 {token}"
return f"未定义颜色 {token}"
return by_maskid, by_color, unclassified
def _load_frame_image(frame: Frame) -> np.ndarray:
@@ -1048,7 +1054,7 @@ async def import_gt_mask(
if resized_to_frame:
label_image = cv2.resize(label_image, (width, height), interpolation=cv2.INTER_NEAREST)
by_maskid, _by_color = _template_class_maps(template)
by_maskid, _by_color, unclassified_class = _template_class_maps(template)
has_template_classes = bool(by_maskid)
fallback_color = _normalize_hex_color(color) or "#22c55e"
@@ -1065,8 +1071,9 @@ async def import_gt_mask(
annotation_label = class_meta["name"]
annotation_color = class_meta["color"]
elif is_unknown:
annotation_label = _gt_unknown_label(label_value)
annotation_color = fallback_color
annotation_label = unclassified_class["name"]
annotation_color = unclassified_class["color"]
class_meta = unclassified_class
else:
annotation_label = f"{label} {label_value}" if len(label_values) > 1 else label
annotation_color = fallback_color

View File

@@ -1664,8 +1664,17 @@ def test_import_gt_mask_handles_unknown_maskid_policy_and_resizes_to_frame(clien
assert undefined_response.status_code == 201
labels = {item["mask_data"]["label"] for item in undefined_response.json()}
assert labels == {"已定义", "未定义类别 2"}
unknown = next(item for item in undefined_response.json() if item["mask_data"]["label"].startswith("未定义"))
assert labels == {"已定义", "待分类"}
unknown = next(item for item in undefined_response.json() if item["mask_data"]["label"] == "待分类")
assert unknown["mask_data"]["color"] == "#000000"
assert unknown["mask_data"]["class"] == {
"id": "reserved-unclassified",
"name": "待分类",
"color": "#000000",
"zIndex": 0,
"maskId": 0,
"category": "系统保留",
}
assert unknown["mask_data"]["gt_unknown_class"] is True
assert unknown["mask_data"]["gt_label_value"] == 2
assert unknown["mask_data"]["gt_resized_to_frame"] is True

View File

@@ -46,7 +46,7 @@ Word 方案描述的理想系统包含:
## 当前代码尚未落地的目标
- SAM 3`sam3_engine.py``sam3_external_worker.py``setup_sam3_env.sh` 作为历史实现保留;由于当前系统不给文本提示,前端不再展示 SAM 3后端 registry 也不暴露 `sam3`。官方没有 SAM 3 tiny/small 权重,当前可选最小真实 SAM 权重仍是 SAM 2.1 tiny。
- GT mask 导入:当前仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图导入,后端会按 maskid 拆分区域,生成高精度 polygon 标注;超出现有类别的 maskid 可舍弃或导入为未定义类别16-bit/uint16 GT_label 和普通彩色类别图会被拒绝尺寸不一致会自动最近邻拉伸到当前帧骨架提取、HDBSCAN 和更复杂的模板自动映射尚未实现。
- GT mask 导入:当前仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图导入,后端会按 maskid 拆分区域,生成高精度 polygon 标注;超出现有类别的 maskid 可舍弃或导入为黑色 `maskid:0` 的“待分类”16-bit/uint16 GT_label 和普通彩色类别图会被拒绝尺寸不一致会自动最近邻拉伸到当前帧骨架提取、HDBSCAN 和更复杂的模板自动映射尚未实现。
- Mask 到点区域的拓扑降维:后端保留 distance transform seed point 数据兼容;前端不再显示黄色 seed point也不提供 seed point 拖拽编辑骨架提取、HDBSCAN 等增强尚未实现。
- 类别优先级融合PNG mask 导出时已按内部优先级生成语义融合 mask前端裁决预览尚未实现。
- 撤销/重做:当前已有全局 mask 历史栈。

View File

@@ -112,6 +112,6 @@
- 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。
- AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。
- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别16-bit/uint16 GT_label 和普通彩色类别图会被拒绝尺寸不同会自动最近邻拉伸到当前帧GT 连通域会生成高精度 polygon导入后和普通 mask 一样不显示黄色 seed point并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹GT_label 固定输出 8-bit uint8 PNG像素值使用类别真实 maskid其中 `maskid:0` 的“待分类”和背景同为 0缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;多边形、矩形、圆和画笔创建遵循“有选中 mask 则并入选中 mask、无选中 mask 才新建”的规则,即使新几何和旧区域不重叠也会组成同一个多 polygon mask无选中分类的新建多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”,画笔无选中 mask 时仍要求右侧语义分类树有 active class`Esc` 只取消选区和临时绘制状态,不删除已有 mask。
- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为黑色 `maskid:0` 的“待分类”16-bit/uint16 GT_label 和普通彩色类别图会被拒绝尺寸不同会自动最近邻拉伸到当前帧GT 连通域会生成高精度 polygon导入后和普通 mask 一样不显示黄色 seed point并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹GT_label 固定输出 8-bit uint8 PNG像素值使用类别真实 maskid其中 `maskid:0` 的“待分类”和背景同为 0缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;多边形、矩形、圆和画笔创建遵循“有选中 mask 则并入选中 mask、无选中 mask 才新建”的规则,即使新几何和旧区域不重叠也会组成同一个多 polygon mask无选中分类的新建多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”,画笔无选中 mask 时仍要求右侧语义分类树有 active class`Esc` 只取消选区和临时绘制状态,不删除已有 mask。
- Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。
- 后端已接入 Bearer JWT 鉴权、共享项目库和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求默认 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。

View File

@@ -79,7 +79,7 @@
| SAM 模型状态徽标 | 真实可用 | 左侧 Sidebar 底部保留紧凑 GPU/CPU 状态徽标;工作区顶栏不再重复显示该徽标,传播权重下拉和自动传播范围摘要只在进入自动传播后显示 |
| 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask回显时保留当前项目帧里尚未保存的 AI/手工 draft mask避免从 AI 页推送的候选被覆盖 |
| “分割结果导出”按钮 | 真实可用 | 原“导出 JSON 标注集”和“导出 PNG Mask ZIP”已合并为一个入口按钮使用 `FileDown` 图标和绿色强调背景,区别于普通灰色操作按钮;点击后可选择整体视频、特定范围帧或当前图片,默认导出范围为当前图片,并勾选导出分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;选择“特定范围帧”后会进入时间轴范围选择模式,可在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧,也可直接修改起止帧输入框;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览;提交前会保存未归档 mask然后调用 `GET /api/export/{project_id}/results` 下载 ZIP浏览器下载名和后端 `Content-Disposition` 均使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;时间戳格式为 `0h00m00s000ms`,帧序号来自项目抽帧后的 1-based 顺序,不使用原视频帧号;包内固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`;选择分开 mask 时包含按帧子目录组织且同类合并的 `分开Mask分割结果/`,选择 GT_label/Pro_label/Mix_label 时分别包含 `GT_label图/``Pro_label彩色分割结果/``Mix_label重叠覆盖彩色分割结果/`。GT_label 图固定为 8-bit uint8 PNG背景为 0语义类别值使用类别真实 maskid`maskid: 0` 的“待分类”与背景同为 0Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 |
| “导入 GT Mask”按钮 | 真实可用 | 入口已从工作区顶栏移动到左侧工具栏“重叠区域去除”之后,使用紫色图标底色;选择图片后先弹出导入结果预览和未知 maskid 策略选择,可舍弃未知类别或导入为未定义类别;随后调用 `POST /api/ai/import-gt-mask`,后端仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,不符合 8-bit 灰度/maskid 图要求时返回错误16-bit/uint16 GT_label 会被拒绝;尺寸不同会自动最近邻拉伸到当前帧,再按类别/连通域生成高精度 polygon 标注,最后回显到工作区;导入 mask 与普通 mask 一样不显示黄色 seed point并共用拓扑锚点统计、边缘平滑、编辑、分类和保存链路 |
| “导入 GT Mask”按钮 | 真实可用 | 入口已从工作区顶栏移动到左侧工具栏“重叠区域去除”之后,使用紫色图标底色;选择图片后先弹出导入结果预览和未知 maskid 策略选择,可舍弃未知类别或导入为黑色 `maskid:0` 的“待分类”;随后调用 `POST /api/ai/import-gt-mask`,后端仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,不符合 8-bit 灰度/maskid 图要求时返回错误16-bit/uint16 GT_label 会被拒绝;尺寸不同会自动最近邻拉伸到当前帧,再按类别/连通域生成高精度 polygon 标注,最后回显到工作区;导入 mask 与普通 mask 一样不显示黄色 seed point并共用拓扑锚点统计、边缘平滑、编辑、分类和保存链路 |
| 参考帧/起止帧/传播权重/AI自动推理 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed左侧工具栏橡皮擦下方有彩色 AI 大脑图标“AI自动推理”入口点击后进入时间轴范围选择模式顶栏才显示独立“传播权重”下拉可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 实例 id、未编辑传播结果的原始 seed 签名和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictor同一参考帧多个同类别 seed 会优先按 `source_instance_id/instance_id` 分开传播,语义 `maskid` 只用于类别/导出worker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon因此传播以新几何参与签名中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏蓝色进度面板显示任务进度、已处理帧次、删除旧区域数和已保存区域数,同一任务 message 不再同时显示在左侧灰色状态文字里;前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask |
| 清空片段遮罩 | 已移除 | 顶栏不再提供重复的“清空片段遮罩”;当前帧清空和 DEL 删除只从左侧工具栏或键盘触发,存在传播链时在同一弹窗提供取消/只清当前帧/按帧范围选择/清空所有传播帧 |
| 保存状态按钮 | 真实可用 | 顶栏按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;未保存 mask 写入 `POST /api/ai/annotate`dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask避免仍显示未保存 |

View File

@@ -47,7 +47,7 @@ Authorization: Bearer <token>
| `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 的 maskid16-bit/uint16 GT_label、全背景 0 图和普通彩色类别图会被拒绝全背景错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”;按模板 `maskId` 匹配类别,未知 maskid 可舍弃或导入为未定义类别;尺寸不同会最近邻拉伸到当前帧,连通域会生成高精度 polygon 标注;导入标注可直接用于 `/api/ai/analyze-mask``/api/ai/smooth-mask`,前端不显示或拖动 seed point |
| `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 的 maskid16-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 |
@@ -303,7 +303,7 @@ SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2`
- `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` 决定舍弃或写为 `gt_unknown_class` 未定义类别。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,中空导入结果通过 `mask_data.holes``metadata.polygonRingCounts` 回显为可编辑内洞,前端不显示黄色 seed point。
- `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。

View File

@@ -155,7 +155,7 @@
- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask不弹确认若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。
- 工作区加载项目帧后会查询已保存标注并回显。
- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`
- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。
- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为黑色 `maskid:0` 的“待分类”,并保留原始 `gt_label_value` 等待后续重新命名。
- 后端导入 GT mask 时必须仅支持 8-bit 二值/灰度 `GT_label图`,以及 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图0 是背景X 是 1-255 的 maskid。灰度/RGB 等通道图按当前模板 `maskId` 匹配类别超出现有类别时按用户选择的策略处理16-bit/uint16 GT_label 和普通彩色 RGB 类别图不再视为合法 GT mask必须返回图片不符合要求的明确错误。
- 后端导入 GT mask 时必须把全背景 0 图视为非法 GT mask返回“GT Mask 图片中没有非背景 maskid 区域。”,前端导入预览也必须保留同一提示并禁止继续导入。
- 导入 GT mask 前端必须提供导入结果预览,显示检测到的 maskid、未知 maskid 和尺寸适配提示;如果 mask 图片尺寸与当前帧不同,后端导入前必须按当前帧长宽用最近邻插值拉伸,使 mask 可适配当前图片。

View File

@@ -267,7 +267,7 @@
- `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。
- `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 区域。”;未知类别按策略舍弃或保存为黑色 `maskid:0` 的“待分类”,并保留 `gt_unknown_class` 和原始 `gt_label_value`。若 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。

View File

@@ -265,7 +265,7 @@ describe('VideoWorkspace', () => {
className: '待分类',
classMaskId: 0,
classId: undefined,
color: '#9ca3af',
color: '#000000',
saved: false,
saveStatus: 'dirty',
metadata: expect.objectContaining({
@@ -1712,8 +1712,8 @@ describe('VideoWorkspace', () => {
const file = new File(['mask'], 'mask.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
expect(screen.getByText('导入结果预览')).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: '导入为未定义' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '导入为未定义' }));
await waitFor(() => expect(screen.getByRole('button', { name: '导入为待分类' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '导入为待分类' }));
await waitFor(() => expect(apiMock.importGtMask).toHaveBeenCalledWith(file, '1', '10', null, {
unknownColorPolicy: 'undefined',

View File

@@ -148,7 +148,7 @@ const classByMaskId = (classes: TemplateClass[]) => new Map(
);
const UNCLASSIFIED_MASK_LABEL = '待分类';
const UNCLASSIFIED_MASK_COLOR = '#9ca3af';
const UNCLASSIFIED_MASK_COLOR = '#000000';
const normalizeMaskAgainstTemplates = (mask: Mask, templates: Template[]): Mask => {
const hasClassReference = Boolean(mask.classId || mask.className || mask.classMaskId !== undefined);
@@ -1430,6 +1430,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const targetImage = targetContext.getImageData(0, 0, targetWidth, targetHeight);
const overlayImage = overlayContext.createImageData(targetWidth, targetHeight);
const classesByMaskId = classByMaskId(gtTemplateClasses);
const hasPositiveTemplateClasses = gtTemplateClasses.some((templateClass) => Number(templateClass.maskId) > 0);
for (let index = 0; index < targetImage.data.length; index += 4) {
const maskId = targetImage.data[index];
const alpha = targetImage.data[index + 3];
@@ -1437,7 +1438,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const templateClass = classesByMaskId.get(maskId);
const [red, green, blue] = templateClass
? parseHexColor(templateClass.color)
: fallbackMaskColor(maskId);
: hasPositiveTemplateClasses
? parseHexColor(UNCLASSIFIED_MASK_COLOR, [0, 0, 0])
: fallbackMaskColor(maskId);
overlayImage.data[index] = red;
overlayImage.data[index + 1] = green;
overlayImage.data[index + 2] = blue;
@@ -1446,7 +1449,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
overlayContext.putImageData(overlayImage, 0, 0);
const sortedMaskIds = Array.from(maskIds).sort((a, b) => a - b);
const unknownMaskIds = gtTemplateClasses.length > 0
const unknownMaskIds = hasPositiveTemplateClasses
? sortedMaskIds.filter((maskId) => !classesByMaskId.has(maskId))
: [];
const resized = sourceWidth !== targetWidth || sourceHeight !== targetHeight;
@@ -2015,7 +2018,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
disabled={gtMaskPreview?.status !== 'ready'}
className="rounded border border-cyan-500/30 bg-cyan-500/15 px-3 py-2 text-xs text-cyan-100 hover:bg-cyan-500/25"
>
</button>
</div>
<button