调整项目库拆帧与长帧序列加载

- 删除项目库右上角独立新建项目入口,保留导入视频/DICOM 自动建项目流程

- 视频项目支持已生成帧后的重新生成帧入口,并提示会清空旧帧、标注和 mask

- 后端重新拆帧任务开始前清理旧帧、旧标注和旧 mask,避免重复帧序列

- 项目帧列表接口默认返回完整帧序列,避免工作区总帧数被 1000 条默认 limit 截断

- 增加可选 docker-compose.gpu.yml,并补充 Docker 使用本机 GPU 的前提和启动说明

- 更新项目库、API 映射、恢复演示文案、后端媒体/项目测试和前端文档
This commit is contained in:
2026-05-07 16:38:13 +08:00
parent 620e95ff91
commit 2a2e6b9b6c
19 changed files with 196 additions and 126 deletions

View File

@@ -216,6 +216,60 @@ def test_parse_task_runner_registers_frames(client, db_session, monkeypatch, tmp
assert frames[0]["source_frame_number"] == 0
def test_parse_task_runner_replaces_existing_frames_and_annotations(client, db_session, monkeypatch, tmp_path):
from models import Annotation, Frame, Mask, ProcessingTask
from services.media_task_runner import run_parse_media_task
project = client.post("/api/projects", json={
"name": "Reparse Me",
"video_path": "uploads/1/clip.mp4",
"source_type": "video",
"status": "ready",
"parse_fps": 30,
"thumbnail_url": "projects/old/thumbnail.jpg",
}).json()
old_frame = Frame(project_id=project["id"], frame_index=0, image_url="projects/old/frame.jpg")
db_session.add(old_frame)
db_session.commit()
db_session.refresh(old_frame)
old_annotation = Annotation(project_id=project["id"], frame_id=old_frame.id, mask_data={"label": "old"})
db_session.add(old_annotation)
db_session.commit()
db_session.refresh(old_annotation)
db_session.add(Mask(annotation_id=old_annotation.id, mask_url="masks/old.png"))
task = ProcessingTask(
task_type="parse_video",
status="queued",
progress=0,
project_id=project["id"],
payload={"source_type": "video", "parse_fps": 10},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
frame_file = tmp_path / "frame_000000.jpg"
frame_file.write_bytes(b"new frame")
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, max_frames=None, target_width=640: ([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_000000.jpg"])
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"] == 1
frames = db_session.query(Frame).filter(Frame.project_id == project["id"]).all()
assert len(frames) == 1
assert frames[0].image_url == f"projects/{project['id']}/frames/frame_000000.jpg"
assert db_session.query(Annotation).filter(Annotation.project_id == project["id"]).count() == 0
assert db_session.query(Mask).count() == 0
def test_parse_dicom_reads_files_in_natural_filename_order(monkeypatch, tmp_path):
from pathlib import Path

View File

@@ -50,6 +50,29 @@ def test_project_crud_and_frames(client, monkeypatch):
assert client.get(f"/api/projects/{project_id}").status_code == 404
def test_list_project_frames_returns_more_than_default_timeline_window(client, db_session, monkeypatch):
monkeypatch.setattr("routers.projects.get_presigned_url", lambda key, expires=3600: f"http://storage/{key}")
created = client.post("/api/projects", json={"name": "Long Sequence", "status": "ready"})
assert created.status_code == 201
project_id = created.json()["id"]
db_session.add_all([
Frame(project_id=project_id, frame_index=index, image_url=f"frames/{index}.jpg")
for index in range(1001)
])
db_session.commit()
frames = client.get(f"/api/projects/{project_id}/frames")
assert frames.status_code == 200
body = frames.json()
assert len(body) == 1001
assert body[-1]["frame_index"] == 1000
limited = client.get(f"/api/projects/{project_id}/frames?limit=2")
assert limited.status_code == 200
assert [frame["frame_index"] for frame in limited.json()] == [0, 1]
def test_delete_project_cascades_related_records(client, db_session):
created = client.post("/api/projects", json={"name": "Cascade Delete"})
assert created.status_code == 201