完善项目导入、模板与分割工作区交互
- 增强 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:
@@ -48,15 +48,37 @@ def test_upload_dicom_batch_filters_files_and_creates_project(client, monkeypatc
|
||||
response = client.post(
|
||||
"/api/media/upload/dicom",
|
||||
files=[
|
||||
("files", ("a.dcm", b"dcm", "application/dicom")),
|
||||
("files", ("10.dcm", b"dcm10", "application/dicom")),
|
||||
("files", ("skip.txt", b"text", "text/plain")),
|
||||
("files", ("2.dcm", b"dcm2", "application/dicom")),
|
||||
("files", ("1.dcm", b"dcm1", "application/dicom")),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["uploaded_count"] == 1
|
||||
assert uploaded == [f"uploads/{data['project_id']}/dicom/a.dcm"]
|
||||
assert data["uploaded_count"] == 3
|
||||
assert uploaded == [
|
||||
f"uploads/{data['project_id']}/dicom/1.dcm",
|
||||
f"uploads/{data['project_id']}/dicom/2.dcm",
|
||||
f"uploads/{data['project_id']}/dicom/10.dcm",
|
||||
]
|
||||
project_detail = client.get(f"/api/projects/{data['project_id']}").json()
|
||||
assert project_detail["name"] == "1.dcm"
|
||||
|
||||
|
||||
def test_upload_dicom_batch_rejects_when_no_valid_dicom(client, monkeypatch):
|
||||
monkeypatch.setattr("routers.media.upload_file", lambda *args, **kwargs: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/media/upload/dicom",
|
||||
files=[
|
||||
("files", ("notes.txt", b"text", "text/plain")),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "No valid DICOM files uploaded"
|
||||
|
||||
|
||||
def test_parse_media_queues_background_task(client, monkeypatch):
|
||||
@@ -194,6 +216,101 @@ def test_parse_task_runner_registers_frames(client, db_session, monkeypatch, tmp
|
||||
assert frames[0]["source_frame_number"] == 0
|
||||
|
||||
|
||||
def test_parse_dicom_reads_files_in_natural_filename_order(monkeypatch, tmp_path):
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from services.frame_parser import parse_dicom
|
||||
|
||||
dcm_dir = tmp_path / "dcm"
|
||||
output_dir = tmp_path / "frames"
|
||||
dcm_dir.mkdir()
|
||||
for name in ["10.dcm", "2.dcm", "1.dcm"]:
|
||||
(dcm_dir / name).write_bytes(b"dcm")
|
||||
|
||||
read_order = []
|
||||
|
||||
class FakeDicom:
|
||||
pixel_array = np.ones((2, 2), dtype=np.uint8)
|
||||
|
||||
def fake_dcmread(path):
|
||||
read_order.append(Path(path).name)
|
||||
return FakeDicom()
|
||||
|
||||
def fake_imwrite(path, image, params=None):
|
||||
Path(path).write_bytes(image.tobytes())
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("services.frame_parser.dcmread", fake_dcmread)
|
||||
monkeypatch.setattr("services.frame_parser.cv2.imwrite", fake_imwrite)
|
||||
|
||||
frame_files = parse_dicom(str(dcm_dir), str(output_dir))
|
||||
|
||||
assert read_order == ["1.dcm", "2.dcm", "10.dcm"]
|
||||
assert [Path(path).name for path in frame_files] == ["frame_000000.jpg", "frame_000001.jpg", "frame_000002.jpg"]
|
||||
|
||||
|
||||
def test_parse_task_runner_downloads_dicom_objects_in_natural_filename_order(client, db_session, monkeypatch, tmp_path):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from models import ProcessingTask
|
||||
from services.media_task_runner import run_parse_media_task
|
||||
|
||||
project = client.post("/api/projects", json={
|
||||
"name": "DICOM",
|
||||
"video_path": "uploads/1/dicom",
|
||||
"source_type": "dicom",
|
||||
"parse_fps": 30,
|
||||
}).json()
|
||||
task = ProcessingTask(
|
||||
task_type="parse_dicom",
|
||||
status="queued",
|
||||
progress=0,
|
||||
project_id=project["id"],
|
||||
payload={"source_type": "dicom"},
|
||||
)
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
db_session.refresh(task)
|
||||
|
||||
class FakeClient:
|
||||
def list_objects(self, bucket, prefix, recursive=True):
|
||||
return [
|
||||
SimpleNamespace(object_name=f"{prefix}/10.dcm"),
|
||||
SimpleNamespace(object_name=f"{prefix}/2.dcm"),
|
||||
SimpleNamespace(object_name=f"{prefix}/1.dcm"),
|
||||
]
|
||||
|
||||
downloaded = []
|
||||
frame_files = []
|
||||
for idx in range(3):
|
||||
frame_file = tmp_path / f"frame_{idx:06d}.jpg"
|
||||
frame_file.write_bytes(b"fake image")
|
||||
frame_files.append(str(frame_file))
|
||||
|
||||
monkeypatch.setattr("services.media_task_runner.get_minio_client", lambda: FakeClient())
|
||||
monkeypatch.setattr(
|
||||
"services.media_task_runner.download_file",
|
||||
lambda object_name: downloaded.append(object_name) or b"dcm",
|
||||
)
|
||||
monkeypatch.setattr("services.media_task_runner.parse_dicom", lambda *args, **kwargs: frame_files)
|
||||
monkeypatch.setattr(
|
||||
"services.media_task_runner.upload_frames_to_minio",
|
||||
lambda frames, project_id: [f"projects/{project_id}/frames/{idx}.jpg" for idx, _ in enumerate(frames)],
|
||||
)
|
||||
monkeypatch.setattr("services.media_task_runner.publish_task_progress_event", lambda task: None)
|
||||
|
||||
result = run_parse_media_task(db_session, task.id)
|
||||
|
||||
assert result["frames_extracted"] == 3
|
||||
assert downloaded == [
|
||||
"uploads/1/dicom/1.dcm",
|
||||
"uploads/1/dicom/2.dcm",
|
||||
"uploads/1/dicom/10.dcm",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_task_runner_skips_already_cancelled_task(db_session):
|
||||
from models import ProcessingTask
|
||||
from services.media_task_runner import run_parse_media_task
|
||||
|
||||
Reference in New Issue
Block a user