212 lines
7.1 KiB
Python
212 lines
7.1 KiB
Python
"""Media upload and parsing endpoints."""
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import get_db
|
|
from minio_client import upload_file, get_presigned_url
|
|
from models import Project, Frame
|
|
from schemas import FrameOut
|
|
from services.frame_parser import parse_video, parse_dicom, upload_frames_to_minio
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/media", tags=["Media"])
|
|
|
|
ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".png", ".jpg", ".jpeg", ".dcm"}
|
|
|
|
|
|
def _get_ext(filename: str) -> str:
|
|
return Path(filename).suffix.lower()
|
|
|
|
|
|
@router.post(
|
|
"/upload",
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Upload a media file",
|
|
)
|
|
async def upload_media(
|
|
file: UploadFile = File(...),
|
|
project_id: Optional[int] = Form(None),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Accept a video, image, or DICOM file and store it in MinIO.
|
|
|
|
If project_id is provided, the video_path of the project is updated.
|
|
Returns the presigned URL of the uploaded object.
|
|
"""
|
|
if not file.filename:
|
|
raise HTTPException(status_code=400, detail="Missing filename")
|
|
|
|
ext = _get_ext(file.filename)
|
|
if ext not in ALLOWED_EXTENSIONS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Unsupported file type: {ext}",
|
|
)
|
|
|
|
data = await file.read()
|
|
object_name = f"uploads/{project_id or 'general'}/{file.filename}"
|
|
|
|
try:
|
|
upload_file(object_name, data, content_type=file.content_type or "application/octet-stream", length=len(data))
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Upload failed: %s", exc)
|
|
raise HTTPException(status_code=500, detail="Upload to storage failed") from exc
|
|
|
|
file_url = get_presigned_url(object_name, expires=3600)
|
|
|
|
if project_id:
|
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
if project:
|
|
project.video_path = object_name
|
|
db.commit()
|
|
logger.info("Linked upload to project_id=%s", project_id)
|
|
else:
|
|
logger.warning("Project id=%s not found for upload linkage", project_id)
|
|
else:
|
|
# Auto-create a project named after the file
|
|
project = Project(
|
|
name=file.filename,
|
|
description="Auto-created from upload",
|
|
status="pending",
|
|
video_path=object_name,
|
|
)
|
|
db.add(project)
|
|
db.commit()
|
|
db.refresh(project)
|
|
project_id = project.id
|
|
object_name = f"uploads/{project_id}/{file.filename}"
|
|
# Re-upload with corrected path
|
|
upload_file(object_name, data, content_type=file.content_type or "application/octet-stream", length=len(data))
|
|
project.video_path = object_name
|
|
db.commit()
|
|
logger.info("Auto-created project id=%s for upload %s", project_id, file.filename)
|
|
|
|
# TODO: enqueue async parsing job (Celery / background task)
|
|
logger.info("Upload complete: %s (size=%d bytes). Async parsing queued.", object_name, len(data))
|
|
|
|
return {
|
|
"object_name": object_name,
|
|
"file_url": file_url,
|
|
"size": len(data),
|
|
"project_id": project_id,
|
|
"message": "Upload successful. Parsing job queued.",
|
|
}
|
|
|
|
|
|
@router.post(
|
|
"/parse",
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
summary="Trigger frame extraction",
|
|
)
|
|
def parse_media(
|
|
project_id: int,
|
|
source_type: str = "video", # video | dicom
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Trigger frame extraction for a project's uploaded media.
|
|
|
|
* video: uses FFmpeg or OpenCV fallback.
|
|
* dicom: uses pydicom to read DCM frames.
|
|
|
|
Extracted frames are uploaded to MinIO and registered in the database.
|
|
"""
|
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
if not project.video_path:
|
|
raise HTTPException(status_code=400, detail="Project has no media uploaded")
|
|
|
|
# Download from MinIO to a temp directory
|
|
from minio_client import download_file
|
|
|
|
try:
|
|
media_bytes = download_file(project.video_path)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Failed to download media for parsing: %s", exc)
|
|
raise HTTPException(status_code=500, detail="Failed to retrieve media from storage") from exc
|
|
|
|
tmp_dir = tempfile.mkdtemp(prefix=f"seg_parse_{project_id}_")
|
|
local_path = os.path.join(tmp_dir, Path(project.video_path).name)
|
|
|
|
with open(local_path, "wb") as f:
|
|
f.write(media_bytes)
|
|
|
|
output_dir = os.path.join(tmp_dir, "frames")
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
try:
|
|
if source_type == "dicom":
|
|
# For DICOM, treat local_path as a directory if it contains multiple .dcm
|
|
# If a single .dcm file was uploaded, put it in its own sub-dir
|
|
dcm_dir = os.path.join(tmp_dir, "dcm")
|
|
os.makedirs(dcm_dir, exist_ok=True)
|
|
if local_path.lower().endswith(".dcm"):
|
|
shutil.move(local_path, os.path.join(dcm_dir, os.path.basename(local_path)))
|
|
else:
|
|
shutil.unpack_archive(local_path, dcm_dir) if shutil.which("unzip") else shutil.move(local_path, dcm_dir)
|
|
frame_files = parse_dicom(dcm_dir, output_dir)
|
|
else:
|
|
frame_files = parse_video(local_path, output_dir, fps=30)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Frame extraction failed: %s", exc)
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
raise HTTPException(status_code=500, detail="Frame extraction failed") from exc
|
|
|
|
# Upload frames to MinIO
|
|
try:
|
|
object_names = upload_frames_to_minio(frame_files, project_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Frame upload failed: %s", exc)
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
raise HTTPException(status_code=500, detail="Frame upload to storage failed") from exc
|
|
|
|
# Register frames in DB
|
|
frames_out = []
|
|
for idx, obj_name in enumerate(object_names):
|
|
# Get image dimensions
|
|
local_frame = frame_files[idx]
|
|
try:
|
|
import cv2
|
|
img = cv2.imread(local_frame)
|
|
h, w = img.shape[:2] if img is not None else (None, None)
|
|
except Exception: # noqa: BLE001
|
|
h, w = None, None
|
|
|
|
frame = Frame(
|
|
project_id=project_id,
|
|
frame_index=idx,
|
|
image_url=obj_name,
|
|
width=w,
|
|
height=h,
|
|
)
|
|
db.add(frame)
|
|
frames_out.append(frame)
|
|
|
|
db.commit()
|
|
for f in frames_out:
|
|
db.refresh(f)
|
|
|
|
# Cleanup temp files
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
project.status = "ready"
|
|
db.commit()
|
|
|
|
logger.info("Parsed %d frames for project_id=%s", len(frames_out), project_id)
|
|
return {
|
|
"project_id": project_id,
|
|
"frames_extracted": len(frames_out),
|
|
"status": "ready",
|
|
"message": "Frame extraction completed successfully.",
|
|
}
|