Files
Pre_Seg_Server/backend/routers/projects.py
admin 2a2e6b9b6c 调整项目库拆帧与长帧序列加载
- 删除项目库右上角独立新建项目入口,保留导入视频/DICOM 自动建项目流程

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

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

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

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

- 更新项目库、API 映射、恢复演示文案、后端媒体/项目测试和前端文档
2026-05-07 16:38:13 +08:00

311 lines
9.3 KiB
Python

"""Project and Frame CRUD endpoints."""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models import Annotation, Mask, Project, Frame, User
from routers.auth import get_current_user, require_editor
from schemas import ProjectCopyRequest, ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut
from minio_client import get_presigned_url
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["Projects"])
def _next_project_copy_name(db: Session, source_name: str) -> str:
base_name = f"{source_name} 副本"
existing_names = {
row[0]
for row in db.query(Project.name)
.filter(Project.name.like(f"{base_name}%"))
.all()
}
if base_name not in existing_names:
return base_name
suffix = 2
while f"{base_name} {suffix}" in existing_names:
suffix += 1
return f"{base_name} {suffix}"
def _prepare_project_response(project: Project) -> Project:
project.frame_count = len(project.frames)
if project.thumbnail_url:
project.thumbnail_url = get_presigned_url(project.thumbnail_url, expires=3600)
return project
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@router.post(
"",
response_model=ProjectOut,
status_code=status.HTTP_201_CREATED,
summary="Create a new project",
)
def create_project(
payload: ProjectCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Project:
"""Create a new segmentation project."""
project = Project(**payload.model_dump(), owner_user_id=current_user.id)
db.add(project)
db.commit()
db.refresh(project)
logger.info("Created project id=%s name=%s", project.id, project.name)
return project
@router.get(
"",
response_model=List[ProjectOut],
summary="List all projects",
)
def list_projects(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[Project]:
"""Retrieve a paginated list of projects."""
projects = (
db.query(Project)
.offset(skip)
.limit(limit)
.all()
)
for p in projects:
_prepare_project_response(p)
return projects
@router.get(
"/{project_id}",
response_model=ProjectOut,
summary="Get a single project",
)
def get_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Project:
"""Retrieve a project by its ID."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return _prepare_project_response(project)
@router.post(
"/{project_id}/copy",
response_model=ProjectOut,
status_code=status.HTTP_201_CREATED,
summary="Copy a project",
)
def copy_project(
project_id: int,
payload: ProjectCopyRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Project:
"""Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata."""
source = db.query(Project).filter(Project.id == project_id).first()
if not source:
raise HTTPException(status_code=404, detail="Project not found")
next_name = (payload.name or "").strip() if payload.name is not None else ""
if not next_name:
next_name = _next_project_copy_name(db, source.name)
copied = Project(
name=next_name,
description=source.description,
video_path=source.video_path,
thumbnail_url=source.thumbnail_url,
status=source.status,
source_type=source.source_type,
original_fps=source.original_fps,
parse_fps=source.parse_fps,
owner_user_id=current_user.id,
)
db.add(copied)
db.flush()
frame_id_map: dict[int, int] = {}
for frame in sorted(source.frames, key=lambda item: item.frame_index):
copied_frame = Frame(
project_id=copied.id,
frame_index=frame.frame_index,
image_url=frame.image_url,
width=frame.width,
height=frame.height,
timestamp_ms=frame.timestamp_ms,
source_frame_number=frame.source_frame_number,
)
db.add(copied_frame)
db.flush()
frame_id_map[frame.id] = copied_frame.id
if payload.mode == "full":
for annotation in sorted(source.annotations, key=lambda item: item.id):
copied_annotation = Annotation(
project_id=copied.id,
frame_id=frame_id_map.get(annotation.frame_id) if annotation.frame_id is not None else None,
template_id=annotation.template_id,
mask_data=annotation.mask_data,
points=annotation.points,
bbox=annotation.bbox,
)
db.add(copied_annotation)
db.flush()
for mask in annotation.masks:
db.add(Mask(
annotation_id=copied_annotation.id,
mask_url=mask.mask_url,
format=mask.format,
))
db.commit()
db.refresh(copied)
logger.info("Copied project id=%s to id=%s mode=%s", project_id, copied.id, payload.mode)
return _prepare_project_response(copied)
@router.patch(
"/{project_id}",
response_model=ProjectOut,
summary="Update a project",
)
def update_project(
project_id: int,
payload: ProjectUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Project:
"""Update project fields partially."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
for key, value in payload.model_dump(exclude_unset=True).items():
if key == "name":
value = (value or "").strip()
if not value:
raise HTTPException(status_code=400, detail="Project name is required")
setattr(project, key, value)
db.commit()
db.refresh(project)
logger.info("Updated project id=%s", project_id)
return project
@router.delete(
"/{project_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a project",
)
def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> None:
"""Delete a project and all related frames and annotations."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
db.delete(project)
db.commit()
logger.info("Deleted project id=%s", project_id)
# ---------------------------------------------------------------------------
# Frames
# ---------------------------------------------------------------------------
@router.post(
"/{project_id}/frames",
response_model=FrameOut,
status_code=status.HTTP_201_CREATED,
summary="Add a frame to a project",
)
def create_frame(
project_id: int,
payload: FrameCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Frame:
"""Register a new frame under a project."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
frame = Frame(project_id=project_id, **payload.model_dump(exclude={"project_id"}))
db.add(frame)
db.commit()
db.refresh(frame)
return frame
@router.get(
"/{project_id}/frames",
response_model=List[FrameOut],
summary="List frames for a project",
)
def list_frames(
project_id: int,
skip: int = 0,
limit: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[Frame]:
"""Retrieve all frames belonging to a project."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = (
db.query(Frame)
.filter(Frame.project_id == project_id)
.order_by(Frame.frame_index)
.offset(skip)
)
if limit is not None:
query = query.limit(limit)
frames = query.all()
for frame in frames:
frame.image_url = get_presigned_url(frame.image_url, expires=3600)
return frames
@router.get(
"/{project_id}/frames/{frame_id}",
response_model=FrameOut,
summary="Get a single frame",
)
def get_frame(
project_id: int,
frame_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Frame:
"""Retrieve a specific frame by ID."""
frame = (
db.query(Frame)
.join(Project, Project.id == Frame.project_id)
.filter(
Frame.project_id == project_id,
Frame.id == frame_id,
)
.first()
)
if not frame:
raise HTTPException(status_code=404, detail="Frame not found")
return frame