完善项目导入、模板与分割工作区交互

- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。

- 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。

- 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。

- 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。

- 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。

- 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。

- 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。

- 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
This commit is contained in:
2026-05-03 17:11:59 +08:00
parent afcddfaeb9
commit 481ffa5b67
47 changed files with 3650 additions and 676 deletions

View File

@@ -169,6 +169,7 @@ def test_export_results_zip_contains_coco_original_images_and_selected_mask_outp
"key": f"template:{annotation['template_id']}",
"template_id": annotation["template_id"],
}]
assert gt_label.dtype == np.uint8
assert gt_label[0, 0] == 0
assert gt_label[20, 50] == 1
assert pro_label[20, 50].tolist() == [212, 182, 6]
@@ -234,6 +235,7 @@ def test_export_results_uses_internal_layer_order_for_gt_pro_and_mix_outputs(cli
cv2.IMREAD_COLOR,
)
assert gt_label.dtype == np.uint8
assert gt_label[10, 10] == high_value
assert pro_label[10, 10].tolist() == [0, 0, 255]
assert mix_label[10, 10].tolist() == [127, 127, 255]
@@ -365,10 +367,74 @@ def test_export_results_preserves_template_maskid_consistently_across_frames(cli
"key": "class:tumor",
"template_id": None,
}]
assert first_label.dtype == np.uint8
assert second_label.dtype == np.uint8
assert first_label[5, 5] == 7
assert second_label[5, 5] == 7
def test_export_results_keeps_unclassified_maskid_zero_black_in_gt_and_pro(client, monkeypatch):
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
project = client.post("/api/projects", json={
"name": "Unclassified Export Project",
"video_path": "uploads/8/unclassified.mp4",
}).json()
frame = client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": 0,
"image_url": "frames/source.jpg",
"width": 20,
"height": 20,
"timestamp_ms": 0,
}).json()
client.post("/api/ai/annotate", json={
"project_id": project["id"],
"frame_id": frame["id"],
"mask_data": {
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
"label": "待分类",
"color": "#000000",
"class": {
"id": "reserved-unclassified",
"name": "待分类",
"color": "#000000",
"maskId": 0,
"zIndex": 0,
},
},
})
response = client.get(f"/api/export/{project['id']}/results?scope=all&outputs=gt_label,pro_label")
assert response.status_code == 200
with zipfile.ZipFile(BytesIO(response.content)) as archive:
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
stem = "unclassified_0h00m00s000ms_frame000001"
gt_label = cv2.imdecode(
np.frombuffer(archive.read(f"GT_label图/{stem}.png"), dtype=np.uint8),
cv2.IMREAD_UNCHANGED,
)
pro_label = cv2.imdecode(
np.frombuffer(archive.read(f"Pro_label彩色分割结果/{stem}.png"), dtype=np.uint8),
cv2.IMREAD_COLOR,
)
assert mapping["classes"] == [{
"gt_pixel_value": 0,
"maskid": 0,
"chineseName": "待分类",
"className": "待分类",
"categoryName": "",
"rgb": [0, 0, 0],
"color": "#000000",
"key": "class:reserved-unclassified",
"template_id": None,
}]
assert gt_label.dtype == np.uint8
assert gt_label[5, 5] == 0
assert pro_label[5, 5].tolist() == [0, 0, 0]
def test_exported_gtlabel_round_trips_through_gt_mask_import_with_template_maskid(client, monkeypatch):
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
project = client.post("/api/projects", json={
@@ -423,6 +489,7 @@ def test_exported_gtlabel_round_trips_through_gt_mask_import_with_template_maski
gt_label = cv2.imdecode(np.frombuffer(exported_gt_label, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
assert gt_label.dtype == np.uint8
assert gt_label[5, 5] == 7
assert mapping["classes"][0]["maskid"] == 7
@@ -446,6 +513,36 @@ def test_exported_gtlabel_round_trips_through_gt_mask_import_with_template_maski
assert imported[0]["mask_data"]["class"]["maskId"] == 7
def test_export_results_rejects_gtlabel_maskid_outside_uint8_range(client, monkeypatch):
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
project = client.post("/api/projects", json={
"name": "Large MaskId Project",
"video_path": "uploads/8/large-maskid.mp4",
}).json()
frame = client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": 0,
"image_url": "frames/source.jpg",
"width": 20,
"height": 20,
}).json()
client.post("/api/ai/annotate", json={
"project_id": project["id"],
"frame_id": frame["id"],
"mask_data": {
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
"label": "TooLarge",
"color": "#ff0000",
"class": {"id": "too-large", "name": "TooLarge", "color": "#ff0000", "maskId": 300, "zIndex": 30},
},
})
response = client.get(f"/api/export/{project['id']}/results?scope=all&outputs=gt_label")
assert response.status_code == 400
assert "8-bit maskid" in response.json()["detail"]
def test_export_missing_project_returns_404(client):
assert client.get("/api/export/999/coco").status_code == 404
assert client.get("/api/export/999/masks").status_code == 404