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

- 增强 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

@@ -1149,6 +1149,81 @@ def test_import_gt_mask_creates_annotations_with_seed_points(client):
assert 0.0 <= body[0]["points"][0][1] <= 1.0
def test_import_gt_mask_polygons_work_with_analysis_and_smoothing(client):
project, frame, _ = _create_project_and_frame(client)
mask = np.zeros((360, 640), dtype=np.uint8)
cv2.ellipse(mask, (260, 160), (130, 70), 20, 0, 360, 1, thickness=-1)
ok, encoded = cv2.imencode(".png", mask)
assert ok
response = client.post(
"/api/ai/import-gt-mask",
data={
"project_id": str(project["id"]),
"frame_id": str(frame["id"]),
"label": "Imported GT",
"color": "#22c55e",
},
files={"file": ("mask.png", encoded.tobytes(), "image/png")},
)
assert response.status_code == 201
annotation = response.json()[0]
assert annotation["mask_data"]["source"] == "gt_mask"
analysis = client.post("/api/ai/analyze-mask", json={
"frame_id": frame["id"],
"mask_data": annotation["mask_data"],
"points": annotation["points"],
"bbox": annotation["bbox"],
})
assert analysis.status_code == 200
assert analysis.json()["topology_anchor_count"] == len(annotation["mask_data"]["polygons"][0])
smoothing = client.post("/api/ai/smooth-mask", json={
"frame_id": frame["id"],
"mask_data": annotation["mask_data"],
"points": annotation["points"],
"bbox": annotation["bbox"],
"strength": 35,
})
assert smoothing.status_code == 200
assert smoothing.json()["topology_anchor_count"] == len(smoothing.json()["polygons"][0])
def test_import_gt_mask_preserves_detailed_contours(client):
project, frame, _ = _create_project_and_frame(client)
mask = np.zeros((360, 640), dtype=np.uint8)
center = np.array([320, 180])
vertices = []
for index in range(96):
angle = 2 * np.pi * index / 96
radius = 120 if index % 2 == 0 else 88
vertices.append([
int(center[0] + np.cos(angle) * radius),
int(center[1] + np.sin(angle) * radius),
])
cv2.fillPoly(mask, [np.array(vertices, dtype=np.int32)], 1)
ok, encoded = cv2.imencode(".png", mask)
assert ok
response = client.post(
"/api/ai/import-gt-mask",
data={
"project_id": str(project["id"]),
"frame_id": str(frame["id"]),
"label": "Detailed GT",
"color": "#22c55e",
},
files={"file": ("mask.png", encoded.tobytes(), "image/png")},
)
assert response.status_code == 201
polygon = response.json()[0]["mask_data"]["polygons"][0]
assert len(polygon) > 80
assert len(polygon) <= 2048
def test_import_gt_mask_splits_label_values(client):
project, frame, _ = _create_project_and_frame(client)
mask = np.zeros((360, 640), dtype=np.uint8)
@@ -1174,7 +1249,27 @@ def test_import_gt_mask_splits_label_values(client):
assert all(len(item["points"]) == 1 for item in body)
def test_import_gt_mask_preserves_low_value_gtlabel_png(client):
def test_import_gt_mask_rejects_background_only_label_image(client):
project, frame, _ = _create_project_and_frame(client)
mask = np.zeros((360, 640), dtype=np.uint8)
ok, encoded = cv2.imencode(".png", mask)
assert ok
response = client.post(
"/api/ai/import-gt-mask",
data={
"project_id": str(project["id"]),
"frame_id": str(frame["id"]),
"label": "GT Class",
},
files={"file": ("empty-gt-label.png", encoded.tobytes(), "image/png")},
)
assert response.status_code == 400
assert response.json()["detail"] == "GT Mask 图片中没有非背景 maskid 区域。"
def test_import_gt_mask_accepts_uint8_low_value_gtlabel_png(client):
project, frame, _ = _create_project_and_frame(client)
template = client.post("/api/templates", json={
"name": "GTLabel Template",
@@ -1185,7 +1280,7 @@ def test_import_gt_mask_preserves_low_value_gtlabel_png(client):
],
"rules": [],
}).json()
mask = np.zeros((360, 640), dtype=np.uint16)
mask = np.zeros((360, 640), dtype=np.uint8)
cv2.rectangle(mask, (40, 40), (140, 140), 1, thickness=-1)
ok, encoded = cv2.imencode(".png", mask)
assert ok
@@ -1241,7 +1336,7 @@ def test_import_gt_mask_rejects_rgb_color_masks(client):
assert "RGB 三通道完全相同" in response.json()["detail"]
def test_import_gt_mask_reads_uint16_gt_label_and_maps_maskid_class(client):
def test_import_gt_mask_rejects_uint16_gt_label(client):
project, frame, _ = _create_project_and_frame(client)
template = client.post("/api/templates", json={
"name": "Label Template",
@@ -1266,13 +1361,8 @@ def test_import_gt_mask_reads_uint16_gt_label_and_maps_maskid_class(client):
files={"file": ("gt_label.png", encoded.tobytes(), "image/png")},
)
assert response.status_code == 201
body = response.json()
assert len(body) == 1
assert body[0]["mask_data"]["gt_label_value"] == 1
assert body[0]["mask_data"]["label"] == "肿瘤"
assert body[0]["mask_data"]["class"]["maskId"] == 1
assert body[0]["mask_data"]["class"]["color"] == "#ff0000"
assert response.status_code == 400
assert "仅支持 8-bit" in response.json()["detail"]
def test_import_gt_mask_handles_unknown_maskid_policy_and_resizes_to_frame(client):