调整项目库拆帧与长帧序列加载
- 删除项目库右上角独立新建项目入口,保留导入视频/DICOM 自动建项目流程 - 视频项目支持已生成帧后的重新生成帧入口,并提示会清空旧帧、标注和 mask - 后端重新拆帧任务开始前清理旧帧、旧标注和旧 mask,避免重复帧序列 - 项目帧列表接口默认返回完整帧序列,避免工作区总帧数被 1000 条默认 limit 截断 - 增加可选 docker-compose.gpu.yml,并补充 Docker 使用本机 GPU 的前提和启动说明 - 更新项目库、API 映射、恢复演示文案、后端媒体/项目测试和前端文档
This commit is contained in:
@@ -340,7 +340,7 @@ celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency
|
|||||||
nohup celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 > /tmp/celery.log 2>&1 &
|
nohup celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 > /tmp/celery.log 2>&1 &
|
||||||
```
|
```
|
||||||
|
|
||||||
视频导入只创建项目并把源视频保存到 MinIO,不会自动拆帧;项目库导入面板会用 Axios 上传回调显示上传进度、百分比和字节数。用户在项目库点击“生成帧”后,再选择目标 FPS 并调用 `POST /api/media/parse`。DICOM 批量导入会在前端选择、后端上传、worker 下载和 pydicom 读取四个环节按文件名自然顺序排序,保证 `1.dcm、2.dcm、10.dcm` 这种序列按可见数字顺序转成项目帧;上传阶段同样显示进度条和本次有效 `.dcm` 文件数量,上传完成后项目库会轮询解析任务进度直到完成、失败或取消。项目卡片支持复制项目:`新项目重置` 会复制项目媒体字段和已生成帧序列但不复制标注,`全内容复制` 会额外复制标注和关联 mask 元数据,任务运行历史不复制。项目库和模板库的成功/失败反馈使用非阻塞短提示,会自动消失,不再用浏览器 `alert()` 阻塞后续操作;项目删除、模板删除、用户改密码/删除和演示出厂重置等高风险操作使用站内确认弹窗。该接口只创建 `processing_tasks` 记录并把任务投递给 Celery;真正的 FFmpeg/OpenCV/pydicom 拆帧由 worker 执行。接口支持 `parse_fps`、`max_frames` 和 `target_width`,用于生成后续 SAM 2 视频处理可复用的标准帧序列;视频/DICOM 解析后都按 `frame_%06d.jpg` 连续生成项目帧,帧表会记录 `timestamp_ms` 和 `source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道,FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的任务进度区展示 queued/running/success/failed/cancelled 最近任务,处理中统计只计算 queued/running;WebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel`、`/api/tasks/{id}/retry` 和 `/api/tasks/{id}` 完成任务取消、重试与失败详情查看。
|
视频导入只创建项目并把源视频保存到 MinIO,不会自动拆帧;项目库导入面板会用 Axios 上传回调显示上传进度、百分比和字节数。用户在项目库点击“生成帧”或“重新生成帧”后,再选择目标 FPS 并调用 `POST /api/media/parse`;已有帧的视频重新生成时,worker 会先清空旧帧、旧标注和旧 mask,避免同一项目出现重复帧序列。DICOM 批量导入会在前端选择、后端上传、worker 下载和 pydicom 读取四个环节按文件名自然顺序排序,保证 `1.dcm、2.dcm、10.dcm` 这种序列按可见数字顺序转成项目帧;上传阶段同样显示进度条和本次有效 `.dcm` 文件数量,上传完成后项目库会轮询解析任务进度直到完成、失败或取消。项目卡片支持复制项目:`新项目重置` 会复制项目媒体字段和已生成帧序列但不复制标注,`全内容复制` 会额外复制标注和关联 mask 元数据,任务运行历史不复制。项目库和模板库的成功/失败反馈使用非阻塞短提示,会自动消失,不再用浏览器 `alert()` 阻塞后续操作;项目删除、模板删除、用户改密码/删除和演示出厂重置等高风险操作使用站内确认弹窗。该接口只创建 `processing_tasks` 记录并把任务投递给 Celery;真正的 FFmpeg/OpenCV/pydicom 拆帧由 worker 执行。接口支持 `parse_fps`、`max_frames` 和 `target_width`,用于生成后续 SAM 2 视频处理可复用的标准帧序列;视频/DICOM 解析后都按 `frame_%06d.jpg` 连续生成项目帧,帧表会记录 `timestamp_ms` 和 `source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。`GET /api/projects/{id}/frames` 默认返回完整帧序列,不会把工作区总帧数截断到 1000。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道,FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的任务进度区展示 queued/running/success/failed/cancelled 最近任务,处理中统计只计算 queued/running;WebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel`、`/api/tasks/{id}/retry` 和 `/api/tasks/{id}` 完成任务取消、重试与失败详情查看。
|
||||||
|
|
||||||
### 步骤 7: 安装前端依赖并构建
|
### 步骤 7: 安装前端依赖并构建
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Project and Frame CRUD endpoints."""
|
"""Project and Frame CRUD endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -261,7 +261,7 @@ def create_frame(
|
|||||||
def list_frames(
|
def list_frames(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 1000,
|
limit: Optional[int] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[Frame]:
|
) -> List[Frame]:
|
||||||
@@ -270,14 +270,15 @@ def list_frames(
|
|||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
frames = (
|
query = (
|
||||||
db.query(Frame)
|
db.query(Frame)
|
||||||
.filter(Frame.project_id == project_id)
|
.filter(Frame.project_id == project_id)
|
||||||
.order_by(Frame.frame_index)
|
.order_by(Frame.frame_index)
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
if limit is not None:
|
||||||
|
query = query.limit(limit)
|
||||||
|
frames = query.all()
|
||||||
for frame in frames:
|
for frame in frames:
|
||||||
frame.image_url = get_presigned_url(frame.image_url, expires=3600)
|
frame.image_url = get_presigned_url(frame.image_url, expires=3600)
|
||||||
return frames
|
return frames
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from typing import Any
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from minio_client import BUCKET_NAME, download_file, get_minio_client, upload_file
|
from minio_client import BUCKET_NAME, download_file, get_minio_client, upload_file
|
||||||
from models import Frame, ProcessingTask, Project
|
from models import Annotation, Frame, Mask, ProcessingTask, Project
|
||||||
from progress_events import publish_task_progress_event
|
from progress_events import publish_task_progress_event
|
||||||
from services.frame_parser import (
|
from services.frame_parser import (
|
||||||
extract_thumbnail,
|
extract_thumbnail,
|
||||||
@@ -109,6 +109,16 @@ def _frame_sequence_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_existing_project_outputs(db: Session, project: Project) -> None:
|
||||||
|
"""Remove stale frame sequence and annotations before regenerating frames."""
|
||||||
|
annotation_ids = db.query(Annotation.id).filter(Annotation.project_id == project.id)
|
||||||
|
db.query(Mask).filter(Mask.annotation_id.in_(annotation_ids)).delete(synchronize_session=False)
|
||||||
|
db.query(Annotation).filter(Annotation.project_id == project.id).delete(synchronize_session=False)
|
||||||
|
db.query(Frame).filter(Frame.project_id == project.id).delete(synchronize_session=False)
|
||||||
|
project.thumbnail_url = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_not_cancelled(db: Session, task: ProcessingTask) -> None:
|
def _ensure_not_cancelled(db: Session, task: ProcessingTask) -> None:
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
if task.status == TASK_STATUS_CANCELLED:
|
if task.status == TASK_STATUS_CANCELLED:
|
||||||
@@ -169,6 +179,7 @@ def run_parse_media_task(db: Session, task_id: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
_ensure_not_cancelled(db, task)
|
_ensure_not_cancelled(db, task)
|
||||||
project.status = PROJECT_STATUS_PARSING
|
project.status = PROJECT_STATUS_PARSING
|
||||||
|
_clear_existing_project_outputs(db, project)
|
||||||
_set_task_state(db, task, status=TASK_STATUS_RUNNING, progress=5, message="后台解析已启动", started=True)
|
_set_task_state(db, task, status=TASK_STATUS_RUNNING, progress=5, message="后台解析已启动", started=True)
|
||||||
|
|
||||||
payload = task.payload or {}
|
payload = task.payload or {}
|
||||||
|
|||||||
@@ -216,6 +216,60 @@ def test_parse_task_runner_registers_frames(client, db_session, monkeypatch, tmp
|
|||||||
assert frames[0]["source_frame_number"] == 0
|
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):
|
def test_parse_dicom_reads_files_in_natural_filename_order(monkeypatch, tmp_path):
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,29 @@ def test_project_crud_and_frames(client, monkeypatch):
|
|||||||
assert client.get(f"/api/projects/{project_id}").status_code == 404
|
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):
|
def test_delete_project_cascades_related_records(client, db_session):
|
||||||
created = client.post("/api/projects", json={"name": "Cascade Delete"})
|
created = client.post("/api/projects", json={"name": "Cascade Delete"})
|
||||||
assert created.status_code == 201
|
assert created.status_code == 201
|
||||||
|
|||||||
@@ -79,10 +79,10 @@
|
|||||||
1. `ProjectLibrary.tsx` 调用 `getProjects()` 获取项目。
|
1. `ProjectLibrary.tsx` 调用 `getProjects()` 获取项目。
|
||||||
2. 上传视频时先 `createProject()`,再 `uploadMedia()`;导入视频不自动调用 `parseMedia()`。
|
2. 上传视频时先 `createProject()`,再 `uploadMedia()`;导入视频不自动调用 `parseMedia()`。
|
||||||
3. 后端 `media.py` 把原始文件上传到 MinIO。
|
3. 后端 `media.py` 把原始文件上传到 MinIO。
|
||||||
4. 用户在项目库点击“生成帧”并选择 FPS 后,`parseMedia()` 创建 `processing_tasks` 记录并投递 Celery worker。
|
4. 用户在项目库点击“生成帧”或“重新生成帧”并选择 FPS 后,`parseMedia()` 创建 `processing_tasks` 记录并投递 Celery worker;已有帧的视频重新生成时,worker 会先删除旧帧、旧标注和旧 mask,再写入新的帧序列。
|
||||||
5. Celery worker 下载 MinIO 文件,调用 `frame_parser.py` 拆帧。
|
5. Celery worker 下载 MinIO 文件,调用 `frame_parser.py` 拆帧。
|
||||||
6. worker 把拆出的帧重新上传 MinIO,写入 `frames` 表,并更新任务状态。
|
6. worker 把拆出的帧重新上传 MinIO,写入 `frames` 表,并更新任务状态。
|
||||||
7. 工作区只通过 `GET /api/projects/{id}/frames` 获取预签名图片 URL;若项目有源视频但无帧,会提示先回项目库生成帧。
|
7. 工作区只通过 `GET /api/projects/{id}/frames` 获取完整预签名图片 URL 列表;若项目有源视频但无帧,会提示先回项目库生成帧。
|
||||||
8. Dashboard 可通过 `POST /api/tasks/{id}/cancel` 取消 queued/running 任务,通过 `POST /api/tasks/{id}/retry` 重试 failed/cancelled 任务,并用 `GET /api/tasks/{id}` 查看失败详情。
|
8. Dashboard 可通过 `POST /api/tasks/{id}/cancel` 取消 queued/running 任务,通过 `POST /api/tasks/{id}/retry` 重试 failed/cancelled 任务,并用 `GET /api/tasks/{id}` 查看失败详情。
|
||||||
|
|
||||||
### 工作区浏览
|
### 工作区浏览
|
||||||
|
|||||||
@@ -58,9 +58,9 @@
|
|||||||
| 项目列表 | 真实可用 | 调用 `GET /api/projects` |
|
| 项目列表 | 真实可用 | 调用 `GET /api/projects` |
|
||||||
| 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 |
|
| 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 |
|
||||||
| 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` |
|
| 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` |
|
||||||
| 新建项目 | 真实可用 | 调用 `POST /api/projects` |
|
| 新建项目 | 已移除入口 | 项目库不再展示独立“新建项目”按钮;导入视频/DICOM 时自动创建项目,后端 `POST /api/projects` 保留给导入流程和兼容调用 |
|
||||||
| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧;上传期间显示项目库导入进度条、百分比和已上传字节 |
|
| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧;上传期间显示项目库导入进度条、百分比和已上传字节 |
|
||||||
| 生成帧按钮 | 真实可用 | 仅对已导入源视频且尚无帧、非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })`;任务入队后项目库继续轮询 `GET /api/tasks/{task_id}`,解析成功后立即重新拉取项目列表,使后端新写入的 `thumbnail_url` 自动刷新到项目封面 |
|
| 生成帧/重新生成帧按钮 | 真实可用 | 对已导入源视频且非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })`;已有帧时显示“重新生成帧”,后端会先清空旧帧、标注和 mask;任务入队后项目库继续轮询 `GET /api/tasks/{task_id}`,解析成功后立即重新拉取项目列表,使后端新写入的 `thumbnail_url` 自动刷新到项目封面 |
|
||||||
| 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS |
|
| 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS |
|
||||||
| 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 |
|
| 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 |
|
||||||
| 导入 DICOM 序列 | 真实可用 | 可上传 `.dcm` 并触发解析;上传前按文件名自然顺序排序,后端解析也保持同一顺序;上传期间显示导入进度条、有效 DICOM 文件数量和已上传字节,上传完成后继续显示解析任务进度直到完成、失败或取消 |
|
| 导入 DICOM 序列 | 真实可用 | 可上传 `.dcm` 并触发解析;上传前按文件名自然顺序排序,后端解析也保持同一顺序;上传期间显示导入进度条、有效 DICOM 文件数量和已上传字节,上传完成后继续显示解析任务进度直到完成、失败或取消 |
|
||||||
@@ -185,6 +185,6 @@
|
|||||||
|
|
||||||
## 总体结论
|
## 总体结论
|
||||||
|
|
||||||
当前前端真实可用的主链路是:JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。
|
当前前端真实可用的主链路是:JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、上传视频/DICOM、显式生成帧/重新生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。
|
||||||
|
|
||||||
当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看。
|
当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看。
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ Authorization: Bearer <token>
|
|||||||
| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 |
|
| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 |
|
||||||
| `uploadMedia(file, projectId, options?)` | `POST /api/media/upload` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度 |
|
| `uploadMedia(file, projectId, options?)` | `POST /api/media/upload` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度 |
|
||||||
| `uploadDicomBatch(files, projectId, options?)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度,上传完成后项目库轮询解析任务进度 |
|
| `uploadDicomBatch(files, projectId, options?)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度,上传完成后项目库轮询解析任务进度 |
|
||||||
| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task;由项目库“生成帧”显式调用,支持 `parse_fps`、`max_frames`、`target_width` |
|
| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task;由项目库“生成帧/重新生成帧”显式调用,已有帧时 worker 会先清空旧帧、标注和 mask;支持 `parse_fps`、`max_frames`、`target_width` |
|
||||||
| `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 |
|
| `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 |
|
||||||
| `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery |
|
| `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery |
|
||||||
| `retryTask(taskId)` | `POST /api/tasks/{task_id}/retry` | 对齐 | 对 failed/cancelled 任务创建新的 queued 重试任务 |
|
| `retryTask(taskId)` | `POST /api/tasks/{task_id}/retry` | 对齐 | 对 failed/cancelled 任务创建新的 queued 重试任务 |
|
||||||
| `getProjectFrames(projectId)` | `GET /api/projects/{id}/frames` | 对齐 | 后端返回预签名 image_url,以及 `timestamp_ms`、`source_frame_number` |
|
| `getProjectFrames(projectId)` | `GET /api/projects/{id}/frames` | 对齐 | 默认返回完整帧列表和预签名 image_url,以及 `timestamp_ms`、`source_frame_number`;可选 `skip/limit` 仅用于显式分页 |
|
||||||
| `predictMask(payload)` | `POST /api/ai/predict` | 对齐 | 前端发送 `image_id/prompt_type/prompt_data/model`,并把后端 `polygons` 转为 `masks[].pathData` |
|
| `predictMask(payload)` | `POST /api/ai/predict` | 对齐 | 前端发送 `image_id/prompt_type/prompt_data/model`,并把后端 `polygons` 转为 `masks[].pathData` |
|
||||||
| `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 单 seed 同步传播接口,供后端兼容和测试使用 |
|
| `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 单 seed 同步传播接口,供后端兼容和测试使用 |
|
||||||
| `queuePropagationTask(payload)` | `POST /api/ai/propagate/task` | 对齐 | 工作区“AI自动推理”入口;创建 Celery 后台任务并由任务表/进度流追踪 |
|
| `queuePropagationTask(payload)` | `POST /api/ai/propagate/task` | 对齐 | 工作区“AI自动推理”入口;创建 Celery 后台任务并由任务表/进度流追踪 |
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
## R2 项目管理
|
## R2 项目管理
|
||||||
|
|
||||||
- 前端展示项目库,并从 `GET /api/projects` 获取项目列表。
|
- 前端展示项目库,并从 `GET /api/projects` 获取项目列表。
|
||||||
- 用户可以新建项目,前端调用 `POST /api/projects`;后端把项目归属到当前登录用户。
|
- 项目库不提供独立“新建项目”按钮;导入视频或 DICOM 时由前端/后端自动创建项目,后端仍保留 `POST /api/projects` 供导入流程和兼容接口使用。
|
||||||
- 用户可以选择项目,进入工作区。
|
- 用户可以选择项目,进入工作区。
|
||||||
- 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。
|
- 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。
|
||||||
- 用户可以对已导入且尚未生成帧的视频项目点击“生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;项目名称编辑状态下不能显示/触发生成帧入口,DICOM 项目不能显示生成帧入口。
|
- 用户可以对已导入源视频的视频项目点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;如果项目已有帧,重新生成前必须清空该项目现有帧、标注和 mask,避免重复帧序列;项目名称编辑状态下不能显示/触发生成帧入口,DICOM 项目不能显示生成帧入口。
|
||||||
- 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。
|
- 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。
|
||||||
- 用户可以在项目库项目卡片上修改项目名称,名称不能为空。
|
- 用户可以在项目库项目卡片上修改项目名称,名称不能为空。
|
||||||
- 用户可以在项目卡片删除按钮旁复制项目;复制时可选择“新项目重置”或“全内容复制”。新项目重置必须复制项目媒体字段和已生成帧序列,但不复制标注或 mask 元数据;全内容复制必须额外复制标注和关联 mask 元数据,并将复制标注重新指向新项目中的对应帧。任务运行历史不复制。
|
- 用户可以在项目卡片删除按钮旁复制项目;复制时可选择“新项目重置”或“全内容复制”。新项目重置必须复制项目媒体字段和已生成帧序列,但不复制标注或 mask 元数据;全内容复制必须额外复制标注和关联 mask 元数据,并将复制标注重新指向新项目中的对应帧。任务运行历史不复制。
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
|
|
||||||
- 工作区根据当前项目加载帧列表。
|
- 工作区根据当前项目加载帧列表。
|
||||||
- 若项目有媒体但无帧,工作区只提示需要先在项目库生成帧,不再自动触发拆帧。
|
- 若项目有媒体但无帧,工作区只提示需要先在项目库生成帧,不再自动触发拆帧。
|
||||||
|
- 工作区加载帧时必须获取项目完整帧序列,不能被接口默认分页截断到 1000 帧。
|
||||||
- Canvas 显示当前帧图片。
|
- Canvas 显示当前帧图片。
|
||||||
- Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。
|
- Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。
|
||||||
- Canvas 未选中特定 mask 时,mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。
|
- Canvas 未选中特定 mask 时,mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;左侧 Sidebar 底部使用 compact 形态显示 GPU/CPU 状态,工作区顶栏不再重复显示,具体传播权重只在进入自动传播后由顶栏下拉负责 |
|
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;左侧 Sidebar 底部使用 compact 形态显示 GPU/CPU 状态,工作区顶栏不再重复显示,具体传播权重只在进入自动传播后由顶栏下拉负责 |
|
||||||
| 登录页 | `src/components/Login.tsx` | 使用 `public/logo.png` 和系统标题文案,调用登录 API,写入 store |
|
| 登录页 | `src/components/Login.tsx` | 使用 `public/logo.png` 和系统标题文案,调用登录 API,写入 store |
|
||||||
| Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 |
|
| Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 |
|
||||||
| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、重命名、删除、导入视频/DICOM、显式生成帧 |
|
| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、重命名、删除、复制、导入视频/DICOM、显式生成帧/重新生成帧 |
|
||||||
| 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板,组织工具栏、Canvas、本体面板、时间轴 |
|
| 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板,组织工具栏、Canvas、本体面板、时间轴 |
|
||||||
| Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask |
|
| Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask |
|
||||||
| 工具栏 | `src/components/ToolsPalette.tsx` | 切换工作区编辑工具、提供等同 `Esc` 的“取消选中”实体按钮、在“重叠区域去除”后触发当前帧/传播链清空、GT Mask 导入和 AI 页面跳转;AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择/取消选中到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、清空遮罩/导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px,按钮列固定 48px,滚动条使用右侧外扩空间和低对比 `seg-scrollbar` |
|
| 工具栏 | `src/components/ToolsPalette.tsx` | 切换工作区编辑工具、提供等同 `Esc` 的“取消选中”实体按钮、在“重叠区域去除”后触发当前帧/传播链清空、GT Mask 导入和 AI 页面跳转;AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择/取消选中到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、清空遮罩/导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px,按钮列固定 48px,滚动条使用右侧外扩空间和低对比 `seg-scrollbar` |
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
|
|
||||||
1. `ProjectLibrary` 创建项目。
|
1. `ProjectLibrary` 创建项目。
|
||||||
2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`。上传期间项目库显示导入进度条、百分比和已上传字节,完成后短暂显示“视频导入完成”。
|
2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`。上传期间项目库显示导入进度条、百分比和已上传字节,完成后短暂显示“视频导入完成”。
|
||||||
3. 用户在项目卡片点击“生成帧”,在弹窗中选择目标 FPS。任务入队后项目库会继续轮询任务进度,解析成功后自动重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面,无需刷新页面或重新进入项目库。
|
3. 用户在视频项目卡片点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS。已有帧的视频重新生成时会提示该操作会清空现有帧序列、标注和 mask;后端任务开始时删除旧帧和旧标注,再写入新的标准帧序列。任务入队后项目库会继续轮询任务进度,解析成功后自动重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面,无需刷新页面或重新进入项目库。
|
||||||
4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps`、`max_frames` 和 `target_width` 指定标准帧序列参数。
|
4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps`、`max_frames` 和 `target_width` 指定标准帧序列参数。
|
||||||
5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧;DICOM 在前端选择、后端上传、worker 下载和 pydicom 读取时都按文件名自然顺序排序;视频/DICOM 解析结果都按 `frame_%06d.jpg` 从 `frame_000000.jpg` 连续命名,视频帧按目标宽度缩放。
|
5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧;DICOM 在前端选择、后端上传、worker 下载和 pydicom 读取时都按文件名自然顺序排序;视频/DICOM 解析结果都按 `frame_%06d.jpg` 从 `frame_000000.jpg` 连续命名,视频帧按目标宽度缩放。
|
||||||
6. worker 写入 `frames.timestamp_ms` 和 `frames.source_frame_number`,并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。
|
6. worker 写入 `frames.timestamp_ms` 和 `frames.source_frame_number`,并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
### 工作区加载
|
### 工作区加载
|
||||||
|
|
||||||
1. `VideoWorkspace` 根据 `currentProject.id` 调用 `getProjectFrames()`。
|
1. `VideoWorkspace` 根据 `currentProject.id` 调用 `getProjectFrames()`。
|
||||||
2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`。
|
2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`;帧列表接口默认返回完整帧序列,工作区右下角总帧数以实际项目帧数为准。
|
||||||
3. 帧数据映射为 store `Frame[]`,包含 `timestampMs` 和 `sourceFrameNumber`,供时间轴和后续视频传播使用。
|
3. 帧数据映射为 store `Frame[]`,包含 `timestampMs` 和 `sourceFrameNumber`,供时间轴和后续视频传播使用。
|
||||||
4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask,但保留没有 `annotationId` 的未保存 draft mask;这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。
|
4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask,但保留没有 `annotationId` 的未保存 draft mask;这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。
|
||||||
5. `VideoWorkspace` 加载项目帧时会优先按当前选中 mask 的 `frameId` 和当前打开帧 id 恢复 `currentFrameIndex`;只有没有可恢复帧时才回到第一帧,避免 AI 页在非第一帧推送回工作区时视角被重置。
|
5. `VideoWorkspace` 加载项目帧时会优先按当前选中 mask 的 `frameId` 和当前打开帧 id 恢复 `currentFrameIndex`;只有没有可恢复帧时才回到第一帧,避免 AI 页在非第一帧推送回工作区时视角被重置。
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
| 需求 | 测试文件 | 覆盖点 |
|
| 需求 | 测试文件 | 覆盖点 |
|
||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 登录页 logo 和系统标题文案、成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口用户图标、底部退出图标和非交互 tooltip、用户 CRUD、唯一 admin/标注员角色权限、审计日志、旧 viewer 归一为标注员、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
|
| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 登录页 logo 和系统标题文案、成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口用户图标、底部退出图标和非交互 tooltip、用户 CRUD、唯一 admin/标注员角色权限、审计日志、旧 viewer 归一为标注员、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
|
||||||
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 JWT 用户隔离 |
|
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目库不展示独立新建项目按钮、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、完整帧列表不默认截断到 1000、项目按当前 JWT 用户隔离 |
|
||||||
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧 FPS 选择、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
|
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧/重新生成帧 FPS 选择、重新生成前清空旧帧旧标注旧 mask、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
|
||||||
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
|
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
|
||||||
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/lib/keyboardShortcuts.test.ts`, `src/store/useStore.test.ts` | 工具切换、切换到多边形/矩形/圆会保留旧 mask 选区、有选中 mask 时多边形/矩形/圆/画笔新几何会并入选中 mask 且不要求重叠、无选中 mask 时手工新建 mask 后自动选中新 mask 并显示创建后边界点、Esc 和左侧“取消选中”按钮清空当前 mask 选区和临时绘制状态、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI自动推理入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、AI自动推理后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔无选中时新建当前类别 mask、画笔/橡皮擦模式下保留当前选中 mask 顶点提示且只读、画笔从图外落笔不创建 mask、靠边画笔生成几何裁剪到当前帧边界内、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、范围确认前重新开始当前帧布尔操作会取消旧顶栏范围请求、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、旧传播缺可靠 lineage 时布尔同步只选每个已选 mask 的空间最近对应实例而不批量处理同类其它实例、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染和 ring 分组保存、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y、物理键码 fallback 和输入框快捷键跳过、撤销/重做历史栈 |
|
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/lib/keyboardShortcuts.test.ts`, `src/store/useStore.test.ts` | 工具切换、切换到多边形/矩形/圆会保留旧 mask 选区、有选中 mask 时多边形/矩形/圆/画笔新几何会并入选中 mask 且不要求重叠、无选中 mask 时手工新建 mask 后自动选中新 mask 并显示创建后边界点、Esc 和左侧“取消选中”按钮清空当前 mask 选区和临时绘制状态、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI自动推理入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、AI自动推理后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔无选中时新建当前类别 mask、画笔/橡皮擦模式下保留当前选中 mask 顶点提示且只读、画笔从图外落笔不创建 mask、靠边画笔生成几何裁剪到当前帧边界内、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、范围确认前重新开始当前帧布尔操作会取消旧顶栏范围请求、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、旧传播缺可靠 lineage 时布尔同步只选每个已选 mask 的空间最近对应实例而不批量处理同类其它实例、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染和 ring 分组保存、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y、物理键码 fallback 和输入框快捷键跳过、撤销/重做历史栈 |
|
||||||
| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
|
| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
| 需求 | 功能点 | 对应测试 | 当前状态 |
|
| 需求 | 功能点 | 对应测试 | 当前状态 |
|
||||||
|------|--------|----------|----------|
|
|------|--------|----------|----------|
|
||||||
| R1 | 登录页 logo 和系统标题文案、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理入口图标、底部退出入口图标和 tooltip 命中范围、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已生成帧自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
|
| R1 | 登录页 logo 和系统标题文案、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理入口图标、底部退出入口图标和 tooltip 命中范围、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已生成帧自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
|
||||||
| R2 | 项目列表/创建/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
|
| R2 | 项目列表/无独立新建项目按钮/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、完整帧列表不默认截断到 1000、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
|
||||||
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
|
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧/重新生成帧、重新生成清理旧帧旧标注旧 mask、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
|
||||||
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
|
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
|
||||||
| R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、清空遮罩唯一左侧入口、Canvas 右下角旧清空/应用分类按钮移除、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
|
| R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、清空遮罩唯一左侧入口、Canvas 右下角旧清空/应用分类按钮移除、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
|
||||||
| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y 和 KeyZ/KeyY fallback、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `keyboardShortcuts.test.ts`, `useStore.test.ts` | 已覆盖 |
|
| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y 和 KeyZ/KeyY fallback、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `keyboardShortcuts.test.ts`, `useStore.test.ts` | 已覆盖 |
|
||||||
|
|||||||
@@ -154,6 +154,26 @@ pip install -r requirements.txt
|
|||||||
cd ..
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker 使用本机 GPU
|
||||||
|
|
||||||
|
Docker 显示 GPU 的前提不是前端开关,而是宿主机、Docker runtime 和容器依赖都可用:
|
||||||
|
|
||||||
|
1. 宿主机 `nvidia-smi` 必须能正常看到 NVIDIA GPU。
|
||||||
|
2. 安装并配置 NVIDIA Container Toolkit。
|
||||||
|
3. Docker compose 需要给 `backend` 和 `worker` 透传 GPU,例如在部署包中使用 `docker-compose.gpu.yml` 覆盖文件。
|
||||||
|
4. 后端镜像内还必须安装 CUDA 版 PyTorch、`sam2` Python 包,并挂载对应 `models/sam2.1_*.pt` 权重;最小部署镜像为了体积默认不安装这些 AI 依赖,因此只加 GPU 透传仍会显示 CPU/模型不可用。
|
||||||
|
|
||||||
|
示例启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build
|
||||||
|
docker compose exec backend python - <<'PY'
|
||||||
|
import torch
|
||||||
|
print(torch.cuda.is_available())
|
||||||
|
PY
|
||||||
|
curl http://localhost:8000/api/ai/models/status
|
||||||
|
```
|
||||||
|
|
||||||
确认关键包:
|
确认关键包:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
| 交互 | 状态机 | 测试 |
|
| 交互 | 状态机 | 测试 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 视频/DICOM 上传 | 选择文件后显示上传进度;DICOM 显示有效文件数量;上传后继续轮询解析任务进度 | `ProjectLibrary.test.tsx` |
|
| 视频/DICOM 上传 | 选择文件后显示上传进度;DICOM 显示有效文件数量;上传后继续轮询解析任务进度 | `ProjectLibrary.test.tsx` |
|
||||||
| 显式生成帧 | 只对视频项目显示;项目名称编辑状态不显示;DICOM 项目不显示;入队后轮询解析任务,成功后刷新项目列表并立即显示新封面 | `ProjectLibrary.test.tsx` |
|
| 显式生成帧/重新生成帧 | 只对有源视频的视频项目显示;项目名称编辑状态不显示;DICOM 项目不显示;已有帧时显示“重新生成帧”并提示会清空旧帧、标注和 mask;入队后轮询解析任务,成功后刷新项目列表并立即显示新封面 | `ProjectLibrary.test.tsx` |
|
||||||
| GT Mask 导入 | 选择文件后预览并选择未知 maskid 策略;非法格式返回错误;尺寸不一致最近邻拉伸;导入结果与普通 mask 同体验 | `VideoWorkspace.test.tsx`、后端 AI 测试 |
|
| GT Mask 导入 | 选择文件后预览并选择未知 maskid 策略;非法格式返回错误;尺寸不一致最近邻拉伸;导入结果与普通 mask 同体验 | `VideoWorkspace.test.tsx`、后端 AI 测试 |
|
||||||
| 分割结果导出 | 默认当前帧;可选整体/范围;范围可用时间轴;导出前保存待归档 mask;按钮带导出图标和绿色强调背景 | `VideoWorkspace.test.tsx`、`api.test.ts`、后端导出测试 |
|
| 分割结果导出 | 默认当前帧;可选整体/范围;范围可用时间轴;导出前保存待归档 mask;按钮带导出图标和绿色强调背景 | `VideoWorkspace.test.tsx`、`api.test.ts`、后端导出测试 |
|
||||||
|
|
||||||
|
|||||||
12
docker-compose.gpu.yml
Normal file
12
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
gpus: all
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: all
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
|
||||||
|
worker:
|
||||||
|
gpus: all
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: all
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
@@ -70,20 +70,11 @@ describe('ProjectLibrary', () => {
|
|||||||
expect(screen.queryByText('30FPS')).not.toBeInTheDocument();
|
expect(screen.queryByText('30FPS')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a new project from the modal', async () => {
|
it('does not expose manual project creation from the project library header', async () => {
|
||||||
apiMock.createProject.mockResolvedValueOnce({ id: 'p2', name: 'New Project', status: 'pending' });
|
|
||||||
|
|
||||||
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
fireEvent.click(screen.getByText('新建项目'));
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('输入项目名称'), { target: { value: 'New Project' } });
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('输入项目描述'), { target: { value: 'desc' } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '创建' }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith({
|
await waitFor(() => expect(apiMock.getProjects).toHaveBeenCalled());
|
||||||
name: 'New Project',
|
expect(screen.queryByRole('button', { name: '新建项目' })).not.toBeInTheDocument();
|
||||||
description: 'desc',
|
|
||||||
}));
|
|
||||||
expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p2' }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports video by creating a project and uploading media without parsing frames', async () => {
|
it('imports video by creating a project and uploading media without parsing frames', async () => {
|
||||||
@@ -156,6 +147,30 @@ describe('ProjectLibrary', () => {
|
|||||||
expect(await screen.findByRole('status')).toHaveTextContent('视频帧生成完成,项目封面已自动更新');
|
expect(await screen.findByRole('status')).toHaveTextContent('视频帧生成完成,项目封面已自动更新');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows regenerating an already parsed video and clears the current workspace cache', async () => {
|
||||||
|
apiMock.getProjects
|
||||||
|
.mockResolvedValueOnce([{ id: 'p-ready', name: 'ready.mp4', status: 'ready', frames: 1500, video_path: 'uploads/ready.mp4', parse_fps: 30, source_type: 'video' }])
|
||||||
|
.mockResolvedValueOnce([{ id: 'p-ready', name: 'ready.mp4', status: 'parsing', frames: 0, video_path: 'uploads/ready.mp4', parse_fps: 24, source_type: 'video' }]);
|
||||||
|
apiMock.parseMedia.mockResolvedValueOnce({ status: 'queued', progress: 0 });
|
||||||
|
useStore.setState({
|
||||||
|
currentProject: { id: 'p-ready', name: 'ready.mp4', status: 'ready' },
|
||||||
|
frames: [{ id: 'old-frame', projectId: 'p-ready', index: 0, url: '/old.jpg', width: 640, height: 360 }],
|
||||||
|
masks: [{ id: 'old-mask', frameId: 'old-frame', pathData: 'M 0 0 Z', label: 'old', color: '#fff' }],
|
||||||
|
selectedMaskIds: ['old-mask'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '重新生成帧' }));
|
||||||
|
expect(screen.getByText('重新生成会清空该项目现有帧序列、标注和 mask,再按新的 FPS 从源视频生成帧。')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '开始生成帧' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p-ready', { parseFps: 30 }));
|
||||||
|
expect(useStore.getState().frames).toEqual([]);
|
||||||
|
expect(useStore.getState().masks).toEqual([]);
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('hides frame generation while editing a project name', async () => {
|
it('hides frame generation while editing a project name', async () => {
|
||||||
apiMock.getProjects.mockResolvedValueOnce([
|
apiMock.getProjects.mockResolvedValueOnce([
|
||||||
{ id: 'p-edit', name: 'Editable Clip', status: 'pending', frames: 0, video_path: 'uploads/editable.mp4', parse_fps: 30, source_type: 'video' },
|
{ id: 'p-edit', name: 'Editable Clip', status: 'pending', frames: 0, video_path: 'uploads/editable.mp4', parse_fps: 30, source_type: 'video' },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react';
|
import { UploadCloud, Film, Settings2, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
import { getProjects, createProject, updateProject, copyProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject, getTask } from '../lib/api';
|
import { getProjects, createProject, updateProject, copyProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject, getTask } from '../lib/api';
|
||||||
@@ -31,15 +31,10 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
const setProjects = useStore((state) => state.setProjects);
|
const setProjects = useStore((state) => state.setProjects);
|
||||||
const currentProject = useStore((state) => state.currentProject);
|
const currentProject = useStore((state) => state.currentProject);
|
||||||
const setCurrentProject = useStore((state) => state.setCurrentProject);
|
const setCurrentProject = useStore((state) => state.setCurrentProject);
|
||||||
const addProject = useStore((state) => state.addProject);
|
|
||||||
const setFrames = useStore((state) => state.setFrames);
|
const setFrames = useStore((state) => state.setFrames);
|
||||||
const setMasks = useStore((state) => state.setMasks);
|
const setMasks = useStore((state) => state.setMasks);
|
||||||
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
const [newDesc, setNewDesc] = useState('');
|
|
||||||
const [showImportMenu, setShowImportMenu] = useState(false);
|
const [showImportMenu, setShowImportMenu] = useState(false);
|
||||||
const [showVideoConfig, setShowVideoConfig] = useState(false);
|
const [showVideoConfig, setShowVideoConfig] = useState(false);
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
@@ -187,11 +182,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
const canGenerateFrames = (project: Project) => (
|
const canGenerateFrames = (project: Project) => (
|
||||||
project.source_type !== 'dicom'
|
project.source_type !== 'dicom'
|
||||||
&& Boolean(project.video_path)
|
&& Boolean(project.video_path)
|
||||||
&& (project.frames ?? 0) === 0
|
|
||||||
&& project.status !== 'parsing'
|
&& project.status !== 'parsing'
|
||||||
&& editingProjectId !== project.id
|
&& editingProjectId !== project.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const frameGenerationLabel = (project: Project) => (
|
||||||
|
(project.frames ?? 0) > 0 ? '重新生成帧' : '生成帧'
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
getProjects()
|
getProjects()
|
||||||
@@ -200,22 +198,6 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, [setProjects]);
|
}, [setProjects]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!newName.trim()) return;
|
|
||||||
setIsCreating(true);
|
|
||||||
try {
|
|
||||||
const project = await createProject({ name: newName.trim(), description: newDesc.trim() || undefined });
|
|
||||||
addProject(project);
|
|
||||||
setShowModal(false);
|
|
||||||
setNewName('');
|
|
||||||
setNewDesc('');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create project:', err);
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (project: Project) => {
|
const handleSelect = (project: Project) => {
|
||||||
setCurrentProject(project);
|
setCurrentProject(project);
|
||||||
onProjectSelect();
|
onProjectSelect();
|
||||||
@@ -395,6 +377,11 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
try {
|
try {
|
||||||
const task = await parseMedia(targetProject.id, { parseFps: frameParseFps });
|
const task = await parseMedia(targetProject.id, { parseFps: frameParseFps });
|
||||||
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
|
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
|
||||||
|
if (currentProject?.id === targetProject.id) {
|
||||||
|
setFrames([]);
|
||||||
|
setMasks([]);
|
||||||
|
setSelectedMaskIds([]);
|
||||||
|
}
|
||||||
await refreshProjects();
|
await refreshProjects();
|
||||||
setShowFrameConfig(false);
|
setShowFrameConfig(false);
|
||||||
setFrameProject(null);
|
setFrameProject(null);
|
||||||
@@ -579,13 +566,6 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
<p className="text-gray-400 text-sm">支持导入视频文件、DICOM序列文件</p>
|
<p className="text-gray-400 text-sm">支持导入视频文件、DICOM序列文件</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
className="flex items-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 text-gray-200 px-5 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={18} />
|
|
||||||
<span>新建项目</span>
|
|
||||||
</button>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowImportMenu(!showImportMenu)}
|
onClick={() => setShowImportMenu(!showImportMenu)}
|
||||||
@@ -762,7 +742,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
className="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-200 hover:bg-cyan-500/20 transition-colors"
|
className="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-200 hover:bg-cyan-500/20 transition-colors"
|
||||||
>
|
>
|
||||||
<Images size={14} />
|
<Images size={14} />
|
||||||
生成帧
|
{frameGenerationLabel(proj)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -888,9 +868,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
{showFrameConfig && frameProject && (
|
{showFrameConfig && frameProject && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">生成帧</h2>
|
<h2 className="text-lg font-semibold text-white mb-4">{frameGenerationLabel(frameProject)}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm text-gray-400">项目: <span className="text-gray-200">{frameProject.name}</span></div>
|
<div className="text-sm text-gray-400">项目: <span className="text-gray-200">{frameProject.name}</span></div>
|
||||||
|
{(frameProject.frames ?? 0) > 0 && (
|
||||||
|
<div className="rounded-lg border border-amber-500/25 bg-amber-950/20 px-3 py-2 text-xs leading-5 text-amber-100">
|
||||||
|
重新生成会清空该项目现有帧序列、标注和 mask,再按新的 FPS 从源视频生成帧。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">生成帧率 (FPS)</label>
|
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">生成帧率 (FPS)</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -926,58 +911,6 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New project modal */}
|
|
||||||
{showModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
||||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">新建项目</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">项目名称</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
|
|
||||||
placeholder="输入项目名称"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">描述(可选)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newDesc}
|
|
||||||
onChange={(e) => setNewDesc(e.target.value)}
|
|
||||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
|
|
||||||
placeholder="输入项目描述"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowModal(false); setNewName(''); setNewDesc(''); }}
|
|
||||||
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={isCreating || !newName.trim()}
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all",
|
|
||||||
isCreating || !newName.trim()
|
|
||||||
? "bg-cyan-500/50 text-black/70 cursor-not-allowed"
|
|
||||||
: "bg-cyan-500 hover:bg-cyan-400 text-black"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCreating && <Loader2 size={14} className="animate-spin" />}
|
|
||||||
创建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ describe('UserAdmin', () => {
|
|||||||
{
|
{
|
||||||
id: '7',
|
id: '7',
|
||||||
name: '演视LC视频序列',
|
name: '演视LC视频序列',
|
||||||
status: 'pending',
|
status: 'ready',
|
||||||
frames: 0,
|
frames: 750,
|
||||||
fps: '30FPS',
|
fps: '30FPS',
|
||||||
source_type: 'video',
|
source_type: 'video',
|
||||||
video_path: 'uploads/7/演视LC视频序列.mp4',
|
video_path: 'uploads/7/演视LC视频序列.mp4',
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export function UserAdmin() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-red-100">演示环境出厂设置</div>
|
<div className="text-sm font-semibold text-red-100">演示环境出厂设置</div>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-red-200/70">
|
<p className="mt-1 text-xs leading-relaxed text-red-200/70">
|
||||||
清空演示过程产生的用户、项目帧、标注、任务和私有模板,只保留默认 admin、“演视LC视频序列”和已按文件名顺序生成帧的“演视DICOM序列”。
|
清空演示过程产生的用户、项目帧、标注、任务和私有模板,只保留默认 admin、已生成帧的“演视LC视频序列”和已按文件名顺序生成帧的“演视DICOM序列”。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -432,7 +432,7 @@ export function UserAdmin() {
|
|||||||
<div className="w-full max-w-lg rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
|
<div className="w-full max-w-lg rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||||
<h2 className="text-lg font-semibold text-white">恢复演示出厂设置</h2>
|
<h2 className="text-lg font-semibold text-white">恢复演示出厂设置</h2>
|
||||||
<p className="mt-2 text-sm leading-relaxed text-red-100/80">
|
<p className="mt-2 text-sm leading-relaxed text-red-100/80">
|
||||||
该操作会删除除默认 admin 外的所有用户、项目帧、标注、任务和私有模板,只保留“演视LC视频序列”和已按文件名顺序生成帧的“演视DICOM序列”。
|
该操作会删除除默认 admin 外的所有用户、项目帧、标注、任务和私有模板,只保留已生成帧的“演视LC视频序列”和已按文件名顺序生成帧的“演视DICOM序列”。
|
||||||
</p>
|
</p>
|
||||||
<label className="mt-4 block text-xs text-gray-400" htmlFor="factory-reset-confirm">
|
<label className="mt-4 block text-xs text-gray-400" htmlFor="factory-reset-confirm">
|
||||||
输入 RESET_DEMO_FACTORY 确认
|
输入 RESET_DEMO_FACTORY 确认
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ describe('api client contracts', () => {
|
|||||||
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
|
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
|
||||||
project: { id: 8, name: '演视DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
|
project: { id: 8, name: '演视DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
|
||||||
projects: [
|
projects: [
|
||||||
{ id: 7, name: '演视LC视频序列', status: 'pending', source_type: 'video', frame_count: 0, video_path: 'uploads/7/演视LC视频序列.mp4' },
|
{ id: 7, name: '演视LC视频序列', status: 'ready', source_type: 'video', frame_count: 750, video_path: 'uploads/7/演视LC视频序列.mp4' },
|
||||||
{ id: 8, name: '演视DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
|
{ id: 8, name: '演视DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
|
||||||
],
|
],
|
||||||
deleted_counts: { users: 1 },
|
deleted_counts: { users: 1 },
|
||||||
@@ -216,7 +216,7 @@ describe('api client contracts', () => {
|
|||||||
admin_user: expect.objectContaining({ username: 'admin' }),
|
admin_user: expect.objectContaining({ username: 'admin' }),
|
||||||
project: expect.objectContaining({ id: '8', name: '演视DICOM序列', frames: 300, source_type: 'dicom' }),
|
project: expect.objectContaining({ id: '8', name: '演视DICOM序列', frames: 300, source_type: 'dicom' }),
|
||||||
projects: [
|
projects: [
|
||||||
expect.objectContaining({ id: '7', name: '演视LC视频序列', frames: 0, source_type: 'video' }),
|
expect.objectContaining({ id: '7', name: '演视LC视频序列', frames: 750, source_type: 'video' }),
|
||||||
expect.objectContaining({ id: '8', name: '演视DICOM序列', frames: 300, source_type: 'dicom' }),
|
expect.objectContaining({ id: '8', name: '演视DICOM序列', frames: 300, source_type: 'dicom' }),
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user