feat: 打通全栈标注闭环、异步拆帧与模型状态

后端能力:

- 新增 Celery app、worker task、ProcessingTask 模型、/api/tasks 查询接口和 media_task_runner,将 /api/media/parse 改为创建后台任务并由 worker 执行 FFmpeg/OpenCV/pydicom 拆帧。

- 新增 Redis 进度事件模块和 FastAPI Redis pub/sub 订阅,将 worker 任务进度广播到 /ws/progress;Dashboard 后端概览接口改为聚合 projects/frames/annotations/templates/processing_tasks。

- 统一项目状态为 pending/parsing/ready/error,新增共享 status 常量,并让前端兼容归一化旧状态值。

- 扩展 AI 后端:新增 SAM registry、SAM2 真实运行状态、SAM3 状态检测与文本语义推理适配入口,以及 /api/ai/models/status GPU/模型状态接口。

- 补齐标注保存/更新/删除、COCO/PNG mask 导出相关后端契约和模板 mapping_rules 打包/解包行为。

前端能力:

- 新增运行时 API/WS 地址推导配置,前端 API 封装对齐 FastAPI 路由、字段映射、任务轮询、标注归档、导出下载和 AI 预测响应转换。

- Dashboard 改为读取 /api/dashboard/overview,并订阅 WebSocket progress/complete/error/status 更新解析队列和实时流转记录。

- 项目库导入视频/DICOM 后创建项目、上传媒体、触发异步解析并刷新真实项目列表。

- 工作区加载真实帧、无帧时触发解析任务、回显已保存标注、保存未归档 mask、更新 dirty mask、清空当前帧后端标注、导出 COCO JSON。

- Canvas 支持当前帧点/框提示调用后端 AI、渲染推理/已保存 mask、应用模板分类并维护保存状态计数;时间轴按项目 fps 播放。

- AI 页面新增 SAM2/SAM3 模型选择,预测请求携带 model;侧边栏和工作区新增真实 GPU/SAM 状态徽标。

- 模板库和本体面板接入真实模板 CRUD、分类编辑、拖拽排序、JSON 导入、默认腹腔镜分类和本地自定义分类选择。

测试与文档:

- 新增 Vitest 配置、前端测试 setup、API/config/websocket/store/组件测试,覆盖登录、项目库、Dashboard、Canvas、工作区、模型状态、时间轴、本体和模板库。

- 新增 pytest 后端测试夹具和 auth/projects/templates/media/AI/export/dashboard/tasks/progress 测试,使用 SQLite、fake MinIO、fake SAM registry 和 Redis monkeypatch 隔离外部服务。

- 新增 doc/ 文档结构,冻结当前需求、设计、接口契约、测试计划、前端逐元素审计、实现地图和后续实施计划,并同步更新 README 与 AGENTS。

验证:

- conda run -n seg_server pytest backend/tests:27 passed。

- npm run test:run:54 passed。

- npm run lint、npm run build、compileall、git diff --check 均通过;Vite 仅提示大 chunk 警告。
This commit is contained in:
2026-05-01 13:29:14 +08:00
parent 4d65c37c73
commit f020ff3b4f
78 changed files with 7089 additions and 456 deletions

142
backend/tests/test_media.py Normal file
View File

@@ -0,0 +1,142 @@
def test_upload_rejects_unsupported_file_type(client):
response = client.post(
"/api/media/upload",
files={"file": ("notes.txt", b"text", "text/plain")},
)
assert response.status_code == 400
assert "Unsupported file type" in response.json()["detail"]
def test_upload_auto_creates_project(client, monkeypatch):
uploaded = []
monkeypatch.setattr("routers.media.upload_file", lambda object_name, data, content_type, length: uploaded.append(object_name))
monkeypatch.setattr("routers.media.get_presigned_url", lambda object_name, expires=3600: f"http://storage/{object_name}")
response = client.post(
"/api/media/upload",
files={"file": ("clip.mp4", b"video", "video/mp4")},
)
assert response.status_code == 201
data = response.json()
assert data["project_id"] is not None
assert data["object_name"] == f"uploads/{data['project_id']}/clip.mp4"
assert uploaded == ["uploads/general/clip.mp4", f"uploads/{data['project_id']}/clip.mp4"]
def test_upload_links_existing_project(client, monkeypatch):
project = client.post("/api/projects", json={"name": "Existing"}).json()
monkeypatch.setattr("routers.media.upload_file", lambda *args, **kwargs: None)
monkeypatch.setattr("routers.media.get_presigned_url", lambda object_name, expires=3600: f"http://storage/{object_name}")
response = client.post(
"/api/media/upload",
data={"project_id": str(project["id"])},
files={"file": ("clip.mp4", b"video", "video/mp4")},
)
assert response.status_code == 201
detail = client.get(f"/api/projects/{project['id']}").json()
assert detail["video_path"] == f"uploads/{project['id']}/clip.mp4"
def test_upload_dicom_batch_filters_files_and_creates_project(client, monkeypatch):
uploaded = []
monkeypatch.setattr("routers.media.upload_file", lambda object_name, data, content_type, length: uploaded.append(object_name))
response = client.post(
"/api/media/upload/dicom",
files=[
("files", ("a.dcm", b"dcm", "application/dicom")),
("files", ("skip.txt", b"text", "text/plain")),
],
)
assert response.status_code == 201
data = response.json()
assert data["uploaded_count"] == 1
assert uploaded == [f"uploads/{data['project_id']}/dicom/a.dcm"]
def test_parse_media_queues_background_task(client, monkeypatch):
project = client.post("/api/projects", json={
"name": "Parse Me",
"video_path": "uploads/1/clip.mp4",
"source_type": "video",
"parse_fps": 5,
}).json()
class FakeAsyncResult:
id = "celery-1"
queued = []
monkeypatch.setattr("routers.media.parse_project_media.delay", lambda task_id: queued.append(task_id) or FakeAsyncResult())
published = []
monkeypatch.setattr("routers.media.publish_task_progress_event", lambda task: published.append(task.id))
response = client.post(f"/api/media/parse?project_id={project['id']}")
assert response.status_code == 202
data = response.json()
assert data["task_type"] == "parse_video"
assert data["status"] == "queued"
assert data["progress"] == 0
assert data["project_id"] == project["id"]
assert data["celery_task_id"] == "celery-1"
assert queued == [data["id"]]
assert published == [data["id"]]
detail = client.get(f"/api/tasks/{data['id']}")
assert detail.status_code == 200
assert detail.json()["status"] == "queued"
project_detail = client.get(f"/api/projects/{project['id']}").json()
assert project_detail["status"] == "parsing"
def test_parse_task_runner_registers_frames(client, db_session, monkeypatch, tmp_path):
from models import ProcessingTask
from services.media_task_runner import run_parse_media_task
project = client.post("/api/projects", json={
"name": "Parse Me",
"video_path": "uploads/1/clip.mp4",
"source_type": "video",
"parse_fps": 5,
}).json()
task = ProcessingTask(
task_type="parse_video",
status="queued",
progress=0,
project_id=project["id"],
payload={"source_type": "video"},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
frame_file = tmp_path / "frame_000001.jpg"
frame_file.write_bytes(b"fake image")
monkeypatch.setattr("services.media_task_runner.download_file", lambda object_name: b"video")
monkeypatch.setattr("services.media_task_runner.parse_video", lambda local_path, output_dir, fps: ([str(frame_file)], 25.0))
monkeypatch.setattr("services.media_task_runner.extract_thumbnail", lambda local_path, thumbnail_path: open(thumbnail_path, "wb").write(b"thumb"))
monkeypatch.setattr("services.media_task_runner.upload_file", lambda *args, **kwargs: None)
monkeypatch.setattr("services.media_task_runner.upload_frames_to_minio", lambda frame_files, project_id: [f"projects/{project_id}/frames/frame_000001.jpg"])
published = []
monkeypatch.setattr(
"services.media_task_runner.publish_task_progress_event",
lambda event_task: published.append((event_task.status, event_task.progress, event_task.message)),
)
result = run_parse_media_task(db_session, task.id)
assert result["frames_extracted"] == 1
db_session.refresh(task)
assert task.status == "success"
assert task.progress == 100
assert ("running", 5, "后台解析已启动") in published
assert ("success", 100, "解析完成") in published
project_detail = client.get(f"/api/projects/{project['id']}").json()
assert project_detail["status"] == "ready"
frames = client.get(f"/api/projects/{project['id']}/frames").json()
assert "frame_000001.jpg" in frames[0]["image_url"]