diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..0bb37a5 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: seoyunje +buy_me_a_coffee: seoyunje \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..8f69fee --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,31 @@ +model: + - changed-files: + - any-glob-to-any-file: ["src/model/**", "models/**", "*.pt", "*.pth"] + +data: + - changed-files: + - any-glob-to-any-file: ["data/**", "datasets/**", "src/data/**"] + +training: + - changed-files: + - any-glob-to-any-file: ["train*.py", "src/train/**", "configs/**"] + +api: + - changed-files: + - any-glob-to-any-file: ["api/**", "routers/**", "app.py"] + +test: + - changed-files: + - any-glob-to-any-file: ["tests/**", "test_*.py"] + +docs: + - changed-files: + - any-glob-to-any-file: ["docs/**", "*.md", "*.rst"] + +ci: + - changed-files: + - any-glob-to-any-file: [".github/**"] + +dependencies: + - changed-files: + - any-glob-to-any-file: ["requirements*.txt", "pyproject.toml", "setup.py"] \ No newline at end of file diff --git a/.github/workflows/issue-auto-label.yml b/.github/workflows/issue-auto-label.yml deleted file mode 100644 index ceec4a2..0000000 --- a/.github/workflows/issue-auto-label.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Auto Label Issues - -on: - issues: - types: [opened] - -jobs: - label: - runs-on: ubuntu-latest - steps: - - name: Label Bug - uses: actions-ecosystem/action-add-labels@v1 - with: - labels: bug diff --git a/.github/workflows/issue-stale-clean.yml b/.github/workflows/issue-stale-clean.yml deleted file mode 100644 index 7f90ca7..0000000 --- a/.github/workflows/issue-stale-clean.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Close stale issues - -on: - schedule: - - cron: "0 0 * * *" - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v9 - with: - days-before-stale: 14 - days-before-close: 7 - stale-issue-label: "stale" - stale-issue-message: > - ⏳ 14일 동안 활동이 없어 자동으로 stale 처리되었습니다. - 추가 정보가 있다면 댓글로 다시 활성화해주세요! - - close-issue-message: > - 🔒 7일 동안 활동이 없어 Issue가 자동으로 닫혔습니다. - 필요하면 언제든 재오픈해주세요 🙂 diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml new file mode 100644 index 0000000..4bbaf9f --- /dev/null +++ b/.github/workflows/pr-ci.yml @@ -0,0 +1,29 @@ +name: PR CI + +on: + pull_request: + branches: [main, develop] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Lint (ruff) + run: ruff check . --output-format=github + + - name: Format check (ruff) + run: ruff format --check . + + - name: Test (pytest) + run: pytest --tb=short -q \ No newline at end of file diff --git a/.github/workflows/pr-label.yml b/.github/workflows/pr-label.yml new file mode 100644 index 0000000..df3de40 --- /dev/null +++ b/.github/workflows/pr-label.yml @@ -0,0 +1,18 @@ +name: PR Auto Label + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + label: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + sync-labels: true # PR 파일 변경 시 라벨 재동기화 \ No newline at end of file diff --git a/.gitignore b/.gitignore index da944b5..d0e86dc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +*.egg-info # [FastAPI / Python - App 폴더 관련] **/__pycache__/ diff --git a/App/celery_app.py b/App/celery_app.py index 027ba15..5503e8b 100644 --- a/App/celery_app.py +++ b/App/celery_app.py @@ -1,18 +1,10 @@ import os -import sys -from pathlib import Path from celery import Celery from dotenv import load_dotenv -current_file = Path(__file__).resolve() -project_root = current_file.parent.parent - -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - +# ------ .env 파일 가져오기 ------ load_dotenv() -# 기본값 설정 REDIS_HOST = os.getenv("REDIS_HOST", "localhost") REDIS_PORT = os.getenv("REDIS_PORT", 6379) @@ -23,7 +15,11 @@ "deepguard", broker=REDIS_URL, # 메세지를 전달할 브로커 backend=REDIS_URL, # 작업 결과를 저장할 백엔드 - include=["services.inference_svc"] # 비동기 추론 작업 파일 + # 비동기 추론 작업 파일 + include=["services.inference_svc", # process_image_task, process_video_task + "services.explain_svc", # process_explain_image_task + "services.image_svc", # cleanup_anonymous_image, cleanup_image_cam + "services.video_svc"] # cleanup_anonymous_video ) # Celery Instance 상세 설정 diff --git a/App/main.py b/App/main.py index 530812c..93fb9b1 100644 --- a/App/main.py +++ b/App/main.py @@ -1,44 +1,35 @@ import os -import sys -from pathlib import Path -import warnings -import logging from dotenv import load_dotenv -# ------ 상위 폴더 경로 설정 -------- -current_file = Path(__file__).resolve() -project_root = current_file.parent.parent # App의 상위 폴더 - -if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - +# ------ .env 파일 가져오기 ------ load_dotenv() # -------- Huggingface_Hub 인증 ---------- if os.getenv("HF_TOKEN"): os.environ["HF_TOKEN"] = os.getenv("HF_TOKEN") +else: + print("[Warning] HF_TOKEN is not set") from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException from fastapi.middleware.cors import CORSMiddleware -from starlette.middleware.sessions import SessionMiddleware -from routes import auth, home, inference, image, video -from utils.common import lifespan -from utils import exc_handler, middleware +# from starlette.middleware.sessions import SessionMiddleware +from routes import api_router +from utils import exc_handler, middleware, common -# 가상 인스턴스 생성 -app = FastAPI(lifespan=lifespan) +# 가상 FastAPI 인스턴스 생성 +app = FastAPI(lifespan=common.lifespan) -# StaticFile 등록하기 +# StaticFile 등록 (이미지, 비디오 파일) app.mount("/static", StaticFiles(directory="static"), name="static") -# Cross Origin Resource Sharing +# CORS Setup for cross-origin requests +# FrontEnd: React, BackEnd: FastAPI origins_str = os.getenv("CORS_ALLOWED_ORIGINS") allowed_origins = [origin.strip() for origin in origins_str.split(",")] - app.add_middleware(CORSMiddleware, allow_origins=allowed_origins, allow_methods=["*"], @@ -52,16 +43,12 @@ # app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, max_age=3600) # 세션 미들웨어 등록 - Redis 이용 -app.add_middleware(middleware.RedisSessionMiddleware, max_age=7200) +app.add_middleware(middleware.RedisSessionMiddleware, max_age=3600) -# 라우터 등록 -app.include_router(auth.router) -app.include_router(home.router) -app.include_router(inference.router) -app.include_router(image.router) -app.include_router(video.router) +# 라우터 등록 (View: Router, Controller: Service) +app.include_router(api_router) -# Custom HTTPException Handler +# 커스텀 예외 처리: HTTPException app.add_exception_handler(StarletteHTTPException, exc_handler.custom_http_exception_handler) -# Custom RequestValidationError Handler +# 커스텀 예외 처리: RequestValidationError app.add_exception_handler(RequestValidationError, exc_handler.validation_exception_handler) \ No newline at end of file diff --git a/App/routes/__init__.py b/App/routes/__init__.py new file mode 100644 index 0000000..a338ab5 --- /dev/null +++ b/App/routes/__init__.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter +from routes.auth import router as auth_router +from routes.explain import router as explain_router +from routes.home import router as home_router +from routes.image import router as image_router +from routes.inference import router as inference_router +from routes.video import router as video_router + +api_router = APIRouter(prefix="/api", tags=["api"]) +api_router.include_router(auth_router) +api_router.include_router(explain_router) +api_router.include_router(home_router) +api_router.include_router(image_router) +api_router.include_router(inference_router) +api_router.include_router(video_router) \ No newline at end of file diff --git a/App/routes/auth.py b/App/routes/auth.py index b640953..4a8154e 100644 --- a/App/routes/auth.py +++ b/App/routes/auth.py @@ -1,20 +1,21 @@ +from pydantic import BaseModel from fastapi import APIRouter, Depends, status, Request from services import auth_svc, session_svc from sqlalchemy import Connection from db.database import context_get_conn from fastapi.exceptions import HTTPException +from fastapi.responses import JSONResponse from schemas.auth_schema import RegisterRequest, LoginRequest router = APIRouter(prefix="/auth", tags=["auth"]) -# --- 로그인 상태 확인 API --- # 프론트엔드 새로고침 시 세션 유지 여부를 확인하고 유저 정보를 반환합니다 -@router.get("/check", status_code=status.HTTP_200_OK) +@router.get("/check", status_code=status.HTTP_200_OK, response_class=JSONResponse, summary= "로그인 상태 확인 API") async def check_session(session_user = Depends(session_svc.get_session_user_opt)): return {"user": session_user} -# --- 회원가입 API --- -@router.post("/register", status_code=status.HTTP_201_CREATED) + +@router.post("/register", status_code=status.HTTP_201_CREATED,response_class=JSONResponse, summary="회원가입 API") async def register_user(request: Request, user_info: RegisterRequest, conn: Connection = Depends(context_get_conn)): @@ -40,8 +41,7 @@ async def register_user(request: Request, return {"message": "회원가입이 성공적으로 완료되었습니다.", "status": "success"} -# --- 로그인 API --- -@router.post("/login", status_code=status.HTTP_200_OK) +@router.post("/login", status_code=status.HTTP_200_OK,response_class=JSONResponse ,summary="로그인 API") async def login_user(request: Request, login_info: LoginRequest, conn: Connection = Depends(context_get_conn)): @@ -73,7 +73,7 @@ async def login_user(request: Request, "status": "success" } -@router.get("/logout", status_code=status.HTTP_200_OK) +@router.get("/logout", status_code=status.HTTP_200_OK, response_class=JSONResponse, summary="로그아웃") async def logout_user(request: Request): request.state.session.clear() return { diff --git a/App/routes/explain.py b/App/routes/explain.py new file mode 100644 index 0000000..adcc763 --- /dev/null +++ b/App/routes/explain.py @@ -0,0 +1,184 @@ +import os +from fastapi import APIRouter, status, Depends +from fastapi.exceptions import HTTPException +from fastapi.responses import JSONResponse +from sqlalchemy import Connection +from db.database import context_get_conn +from schemas.explain_schema import ExplainImageRequest, ExplainFrameRequest +from services import session_svc, explain_svc, image_svc, video_svc +from celery_app import celery_app +from celery.result import AsyncResult + +router = APIRouter(prefix="/explain", tags=["explain"]) + +@router.post("/image/{image_id}", status_code=status.HTTP_202_ACCEPTED, + response_class=JSONResponse, summary="딥페이크 이미지 위조 흔적 시각화 비동기 접수") +async def explain_image( + image_id: int, + explain_req: ExplainImageRequest, + conn: Connection = Depends(context_get_conn), + session_user = Depends(session_svc.get_session_user_prt), # 로그인 필수 +): + # 딥페이크 이미지 추론 결과 가져오기 + result = await image_svc.get_image_result(conn, image_id) + + if result.status != "SUCCESS": + raise HTTPException( + status_code = status.HTTP_400_BAD_REQUEST, + detail = "이미지 위조 흔적 분석은 추론이 성공한 이미지만 가능합니다" + ) + + image_path = "." + result.image_loc + + if not os.path.exists(image_path): + raise HTTPException( + status_code = status.HTTP_404_NOT_FOUND, + detail = f"요청하신 이미지 파일을 찾을 수 없습니다. 삭제하였는지 다시 확인해주세요." + ) + + if result.model_type == "pro" and explain_req.aug_smooth: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Pro 모델은 aug_smooth 기능을 지원하지 않습니다", + ) + + # Celery Task 호출(Redis Broker 활용) + task = explain_svc.process_explain_image_task.delay( + user_email = session_user["email"], + version_type = result.version_type, + domain_type = result.domain_type, + image_loc = result.image_loc, + image_id = result.image_id, + category = 1 if result.label == "FAKE" else 0, + explain_req_dict = explain_req.model_dump()) + return { + "message": "딥페이크 이미지 위조 흔적 시각화 접수 완료. 시각화 분석 시작 ...", + "task_id": task.id, + } + + +@router.get("/image/result/{task_id}", status_code=status.HTTP_200_OK, + response_class=JSONResponse, summary="딥페이크 이미지 위조 흔적 시각화 결과 가져오기") +async def get_explain_image_result( + task_id: str, + session_user = Depends(session_svc.get_session_user_prt), # 로그인 필수 + ): + + # Redis Broker에서 Task ID에 해당하는 비동기 작업 상태 가져오기 + task = AsyncResult(task_id, app=celery_app) + + if task.state in ("PENDING", "STARTED", "RETRY"): + return JSONResponse( + status_code = status.HTTP_202_ACCEPTED, + content = {"message": "딥페이크 이미지 위조 흔적 시각화 분석 중 ..."} + ) + + if task.state == "FAILURE": + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="딥페이크 이미지 위조 흔적 시각화 중 알 수 없는 오류가 발생하였습니다") + + # Celery Task 결과 가져오기 + result = task.result + + if result["status"] == "FAILED": + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=result["message"]) + + return { + "status": result["status"], + "message": result["message"], + "cam_loc": result["cam_loc"], + } + +@router.post("/video/{video_id}/frame/{frame_index}", status_code=status.HTTP_202_ACCEPTED, + response_class=JSONResponse, summary="딥페이크 비디오 프레임 위조 흔적 시각화 비동기 접수") +async def explain_frame( + video_id: int, + frame_index: int, + explain_req: ExplainFrameRequest, + conn: Connection = Depends(context_get_conn), + session_user = Depends(session_svc.get_session_user_prt), # 로그인 필수 +): + # 딥페이크 비디오 추론 결과 가져오기 + result = await video_svc.get_video_result(conn, video_id) + + # 딥페이크 비디오 추론 성공 여부 확인하기 + if result.status != "SUCCESS": + raise HTTPException( + status_code = status.HTTP_400_BAD_REQUEST, + detail = "비디오 프레임 위조 흔적 분석은 추론이 성공한 비디오에서만 가능합니다" + ) + + # 비디오 파일 저장 경로 가져오기 + video_path = "." + result.video_loc + if not os.path.exists(video_path): + raise HTTPException( + status_code = status.HTTP_404_NOT_FOUND, + detail = f"요청하신 비디오 파일을 찾을 수 없습니다. 삭제하였는지 다시 확인해주세요." + ) + + # 딥페이크 비디오 프레임 위조 흔적 분석 (pro model는 aug_smooth 사용 불가, 연산이 너무 많아짐) + if result.model_type == "pro" and explain_req.aug_smooth: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Pro 모델은 aug_smooth 기능을 지원하지 않습니다", + ) + + # 비디오 내 해당 frame이 몇초에 위치한 frame인지 확인 + frame_time = await video_svc.get_video_frame_by_index(conn, video_id, frame_index) + + # Celery Task 호출(Redis Broker 활용) + task = explain_svc.process_explain_frame_task.delay( + user_email = session_user["email"], + version_type = result.version_type, + domain_type = result.domain_type, + video_loc = result.video_loc, + video_id = video_id, + category = 1 if result.label == "FAKE" else 0, + frame_time = frame_time, + explain_req_dict = explain_req.model_dump()) + return { + "message": "딥페이크 비디오 프레임 위조 흔적 시각화 접수 완료. 시각화 분석 시작 ...", + "task_id": task.id, + } + +@router.get("/frame/result/{task_id}", status_code=status.HTTP_200_OK, + response_class=JSONResponse, summary="딥페이크 비디오 프레임 위조 흔적 시각화 결과 가져오기") +async def get_explain_frame_result( + task_id: str, + session_user = Depends(session_svc.get_session_user_prt), # 로그인 필수 + ): + + # Redis Broker에서 Task ID에 해당하는 비동기 작업 상태 가져오기 + task = AsyncResult(task_id, app=celery_app) + + # 비동기 작업 진행 상태 Check + if task.state in ("PENDING", "STARTED", "RETRY"): + return JSONResponse( + status_code = status.HTTP_202_ACCEPTED, + content = {"message": "딥페이크 비디오 프레임 위조 흔적 시각화 분석 중 ..."} + ) + + # 비동기 작업 실패 + if task.state == "FAILURE": + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="딥페이크 비디오 프레임 위조 흔적 시각화 중 알 수 없는 오류가 발생하였습니다") + + # Celery Task 결과 가져오기 + result = task.result + + # 딥페이크 비디오 프레임 위조 흔적 시각화 생성 또는 파일 저장 도중 오류 발생 + if result["status"] == "FAILED": + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=result["message"]) + + return { + "status": result["status"], + "message": result["message"], + "cam_loc": result["cam_loc"], + } + \ No newline at end of file diff --git a/App/routes/home.py b/App/routes/home.py index 6a2a87f..7f65ab9 100644 --- a/App/routes/home.py +++ b/App/routes/home.py @@ -1,9 +1,10 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, status from services import session_svc +from fastapi.responses import JSONResponse router = APIRouter(prefix="/home", tags=["home"]) -@router.get("/") +@router.get("", status_code=status.HTTP_200_OK,response_class=JSONResponse, summary="세션 유저 정보 가져오기") async def home_ui(session_user = Depends(session_svc.get_session_user_opt)): return { "session_user": session_user diff --git a/App/routes/image.py b/App/routes/image.py index 94c63d2..d8fea0e 100644 --- a/App/routes/image.py +++ b/App/routes/image.py @@ -2,11 +2,12 @@ from services import session_svc, image_svc from sqlalchemy import Connection from db.database import context_get_conn +from fastapi.responses import JSONResponse router = APIRouter(prefix="/image", tags=["image"]) # 사용자 전체 업로드 히스토리 조회 -@router.get("/history", status_code=status.HTTP_200_OK) +@router.get("/history", status_code=status.HTTP_200_OK, response_class=JSONResponse,summary="이미지 히스토리 전체 조회") async def get_user_histories( conn: Connection = Depends(context_get_conn), session_user = Depends(session_svc.get_session_user_prt) # 로그인 필수 @@ -22,7 +23,7 @@ async def get_user_histories( } # 특정 이미지의 상세 내역 조회 (개별 히스토리) -@router.get("/history/{image_id}", status_code=status.HTTP_200_OK) +@router.get("/history/{image_id}", status_code=status.HTTP_200_OK, response_class=JSONResponse,summary="이미지 개별 상세 조회") async def get_user_history( image_id: int, conn: Connection = Depends(context_get_conn), @@ -40,7 +41,7 @@ async def get_user_history( } # 이미지 히스토리 삭제 -@router.delete("/history/{image_id}", status_code=status.HTTP_200_OK, summary="버튼 삭제") +@router.delete("/history/{image_id}", status_code=status.HTTP_200_OK,response_class=JSONResponse, summary="이미지 버튼 히스토리 삭제") async def delete_image_history( image_id: int, conn: Connection = Depends(context_get_conn), @@ -51,8 +52,9 @@ async def delete_image_history( if not history: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="해당 이미지 기록을 찾을 수 없습니다.") + # DB 레코드 삭제 await image_svc.delete_image_db(conn, image_id) - + # 실제 이미지 삭제 await image_svc.delete_image(history.image_loc) return { diff --git a/App/routes/inference.py b/App/routes/inference.py index ba856af..35b0ebe 100644 --- a/App/routes/inference.py +++ b/App/routes/inference.py @@ -1,22 +1,24 @@ from fastapi import ( - APIRouter, Depends, status, Form, - File, UploadFile -) + APIRouter, UploadFile, status, + Depends, Form, File) +from fastapi.responses import JSONResponse from fastapi.exceptions import HTTPException from services import image_svc, session_svc, inference_svc, video_svc from sqlalchemy import Connection from db.database import context_get_conn -from schemas.video_schema import VideoDetailResponse - +from schemas.video_schema import VideoDetailResponse, VideoDetailData +from schemas.image_schema import ImageResultData +from typing import Literal router = APIRouter(prefix="/inference", tags=["inference"]) -@router.post("/image", status_code=status.HTTP_202_ACCEPTED, summary="딥페이크 비동기 이미지 추론 접수") +@router.post("/image", status_code=status.HTTP_202_ACCEPTED, + response_class=JSONResponse, summary="딥페이크 비동기 이미지 추론 접수") async def predict_image( imagefile: UploadFile = File(...), # 사용자가 업로드한 이미지 객체 - version_type: str = Form(...), # deepguard1, deepguard2 - model_type: str = Form(...), # fast model, pro moel - domain_type: str = Form(...), # model 학습시 사용한 dataset 종류 + version_type: Literal["v1","v2"] = Form("v2", description="모델 엔진 버전"), + model_type: Literal["fast","pro"] = Form("fast", description="추론 모드 (fast: 속도 우선, pro: 정확도 우선)"), + domain_type: Literal["서양인","동양인"] = Form("서양인", description="학습 데이터셋 도메인"), conn: Connection = Depends(context_get_conn), # 이미지 File 저장 session_user = Depends(session_svc.get_session_user_opt) # Signed Cookie 없을 시 None 반환 ): @@ -43,7 +45,8 @@ async def predict_image( "status": "success", } -@router.get("/image/{image_id}", status_code=status.HTTP_200_OK, summary="딥페이크 비동기 이미지 추론 결과값 가져오기") +@router.get("/image/{image_id}", status_code=status.HTTP_200_OK, + response_model=ImageResultData, summary="딥페이크 비동기 이미지 추론 결과값 가져오기") async def get_image_result( image_id: int, conn: Connection = Depends(context_get_conn), @@ -62,12 +65,13 @@ async def get_image_result( ) return image_data -@router.post("/video", status_code=status.HTTP_202_ACCEPTED, summary="딥페이크 비동기 비디오 추론 접수") +@router.post("/video", status_code=status.HTTP_202_ACCEPTED, + response_class=JSONResponse, summary="딥페이크 비동기 비디오 추론 접수") async def predict_video( videofile: UploadFile = File(...), # 사용자가 업로드한 비디오 객체 - version_type: str = Form(...), # deepguard1, deepguard2 - model_type: str = Form(...), # fast model, pro moel - domain_type: str = Form(...), # model 학습시 사용한 dataset 종류 + version_type: Literal["v1","v2"] = Form("v2", description="모델 엔진 버전"), + model_type: Literal["fast","pro"] = Form("fast", description="추론 모드 (fast: 속도 우선, pro: 정확도 우선)"), + domain_type: Literal["서양인","동양인"] = Form("서양인", description="학습 데이터셋 도메인"), conn: Connection = Depends(context_get_conn), # 비디오 File 저장 session_user = Depends(session_svc.get_session_user_opt) # Signed Cookie 없을 시 None 반환 ): @@ -94,7 +98,8 @@ async def predict_video( "status": "success", } -@router.get("/video/{video_id}", status_code=status.HTTP_200_OK, summary="딥페이크 비디오 비디오 추론 결과값 가져오기") +@router.get("/video/{video_id}", status_code=status.HTTP_200_OK, + response_model=VideoDetailData, summary="딥페이크 비디오 추론 결과값 가져오기") async def get_video_result( video_id: int, conn: Connection = Depends(context_get_conn), @@ -117,13 +122,21 @@ async def get_video_result( return video_data -@router.get("/video/{video_id}/detail", status_code=status.HTTP_200_OK, summary="딥페이크 상세 분석 결과값 가져오기") +@router.get("/video/{video_id}/detail", status_code=status.HTTP_200_OK, + response_model=VideoDetailResponse ,summary="딥페이크 상세 분석 결과값 가져오기") async def get_video_detail( video_id: int, conn: Connection = Depends(context_get_conn), - session_user = Depends(session_svc.get_session_user_prt) # 로그인 유저만 가능 + session_user = Depends(session_svc.get_session_user_prt) # 로그인 필수 ): + video_data = await video_svc.get_video_result(conn, video_id) + if video_data.status != "SUCCESS": + raise HTTPException( + status_code = status.HTTP_400_BAD_REQUEST, + detail = f"비디오 상세 분석은 추론이 성공한 비디오만 가능합니다" + ) + meta = await video_svc.get_video_meta_result(conn, video_id) - frames = await video_svc.get_video_frame_results(conn, video_id) + frames = await video_svc.get_video_frame_result(conn, video_id) return VideoDetailResponse(meta=meta, frames=frames) diff --git a/App/routes/video.py b/App/routes/video.py index 5ccb134..cd713af 100644 --- a/App/routes/video.py +++ b/App/routes/video.py @@ -1,12 +1,15 @@ -from fastapi import APIRouter, Depends, status, HTTPException +from fastapi import APIRouter, Depends, status +from fastapi.exceptions import HTTPException +from fastapi.responses import JSONResponse from services import session_svc, video_svc from sqlalchemy import Connection from db.database import context_get_conn +from fastapi.responses import JSONResponse router = APIRouter(prefix="/video", tags=["video"]) # 사용자 전체 비디오 업로드 히스토리 조회 -@router.get("/history", status_code=status.HTTP_200_OK) +@router.get("/history", status_code=status.HTTP_200_OK, response_class=JSONResponse,summary="비디오 히스토리 전체 조회") async def get_video_histories( conn: Connection = Depends(context_get_conn), session_user = Depends(session_svc.get_session_user_prt) # 로그인 필수 @@ -22,14 +25,14 @@ async def get_video_histories( } # 특정 비디오의 상세 내역 조회 -@router.get("/history/{video_id}", status_code=status.HTTP_200_OK) +@router.get("/history/{video_id}", status_code=status.HTTP_200_OK,response_class=JSONResponse, summary="비디오 개별 상세 조회") async def get_video_history( video_id: int, conn: Connection = Depends(context_get_conn), - session_user = Depends(session_svc.get_session_user_prt) # 로그인 필수 + session_user = Depends(session_svc.get_session_user_opt) ): - user_id = session_user['id'] + user_id = session_user['id'] if session_user else None # 본인의 데이터만 조회할 수 있도록 user_id도 함께 전달 history = await video_svc.get_user_history(conn, user_id, video_id) @@ -43,7 +46,8 @@ async def get_video_history( } # 비디오 히스토리 삭제 -@router.delete("/history/{video_id}", status_code=status.HTTP_200_OK, summary="버튼 삭제") +@router.delete("/history/{video_id}", status_code=status.HTTP_200_OK, + response_class=JSONResponse, summary="비디오 버튼 히스토리 삭제") async def delete_video_history( video_id: int, conn: Connection = Depends(context_get_conn), @@ -56,8 +60,11 @@ async def delete_video_history( if not history: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="해당 비디오 기록을 찾을 수 없습니다.") + if history.status == "SUCCESS": + await video_svc.delete_video_meta_result(conn, video_id) + await video_svc.delete_video_frame_result(conn, video_id) + await video_svc.delete_video_db(conn, video_id) - await video_svc.delete_video(history.video_loc) return { diff --git a/App/schemas/auth_schema.py b/App/schemas/auth_schema.py index b844abd..a5c453c 100644 --- a/App/schemas/auth_schema.py +++ b/App/schemas/auth_schema.py @@ -1,20 +1,28 @@ from pydantic import BaseModel, EmailStr, Field -# [응답용] 프론트엔드 반환용 유저 기본 정보 (보안상 비밀번호 제외) class UserData(BaseModel): - id: int - name: str - email: str + """ + [응답용] 프론트엔드 반환용 유저 기본 정보 (보안상 비밀번호 제외) + """ + id: int = Field(..., description="유저 고유 ID") + name: str = Field(..., description="유저 이름") + email: str = Field(..., description="유저 이메일") -# [내부용] DB 조회 및 서버 내부 인증용 (UserData 상속 + 암호화된 비밀번호) class UserDataPASS(UserData): - hashed_password: str + """ + [내부용] DB 조회 및 서버 내부 인증용 (UserData 상속 + 암호화된 비밀번호) + """ + hashed_password: str = Field(..., description="bcrypt로 해싱된 비밀번호 (서버 내부 인증용, 외부 노출 금지)") -# [요청용] 로그인 API (POST /auth/login) 수신 JSON 데이터 검증 class LoginRequest(BaseModel): - email: EmailStr - password: str = Field(min_length=8, max_length=30) + """ + [요청용] 로그인 API (POST /auth/login) 수신 JSON 데이터 검증 + """ + email: EmailStr = Field(..., description="로그인 이메일") + password: str = Field(min_length=8, max_length=30, description="비밀번호 (8자 이상 30자 이하)") -# [요청용] 회원가입 API (POST /auth/register) 수신 JSON 데이터 검증 class RegisterRequest(LoginRequest): - name: str = Field(min_length=2, max_length=100) + """ + [요청용] 회원가입 API (POST /auth/register) 수신 JSON 데이터 검증 + """ + name: str = Field(min_length=2, max_length=100, description="유저 이름 (2자 이상 100자 이하)") \ No newline at end of file diff --git a/App/schemas/explain_schema.py b/App/schemas/explain_schema.py new file mode 100644 index 0000000..86d3563 --- /dev/null +++ b/App/schemas/explain_schema.py @@ -0,0 +1,86 @@ +from typing import Literal +from pydantic import BaseModel, Field, model_validator + +# ── Branch별 허용 기법 ──────────────────────────────────── +# +# [LOW branch] 고해상도 feature map → 국소 위조 흔적 포착에 특화 +# - hirescam : activation * gradient element-wise 곱, 업샘플링 없이 원본 해상도 유지 +# → 픽셀 단위 경계가 가장 선명 (aug_smooth: O, eigen_smooth: X) +# - gradcamelementwise : 각 위치별 gradient 부호 고려 후 ReLU → 노이즈 억제, 국소 영역 정밀 (aug_smooth: O, eigen_smooth: O) +# - layercam : positive gradient로 activation 공간 가중합산 +# → 얕은 레이어에서도 안정적, 텍스처/경계 검출에 강함 (aug_smooth: O, eigen_smooth: O) +# +# [HIGH branch] 저해상도 semantic feature map → 전체 구조 파악에 특화 +# - eigengradcam : activation * gradient 의 첫 번째 주성분 +# → 클래스 판별력 유지하면서 GradCAM보다 부드럽고 EigenCAM보다 정확 (aug_smooth: O, eigen_smooth: X) +# - gradcamplusplus : 2차 gradient 활용 → 다중 위조 영역 동시 포착, 작은 영역에 강함 (aug_smooth: O, eigen_smooth: X) +# - xgradcam : gradient를 normalized activation으로 스케일링 +# → attribution 합이 logit 변화량과 수학적으로 일치, 충실도 높음 (aug_smooth: O, eigen_smooth: O) + +_LOW_ALLOWED = {"hirescam", "gradcamelementwise", "layercam"} +_HIGH_ALLOWED = {"eigengradcam", "gradcamplusplus", "xgradcam"} +_EIGEN_ALLOWED = {"gradcamelementwise", "layercam", "xgradcam"} + + +class ExplainImageRequest(BaseModel): + """ + [이미지 시각화 요청] 딥페이크 이미지 위조 흔적 CAM 시각화 파라미터 + + - routes: explain (POST /explain/image/{image_id}) + - services: explain_svc.process_explain_image_task + """ + model_type: Literal["fast", "pro"] = Field("fast", + description="추론 모드 (fast: 속도 우선, pro: 정확도 우선)") + + branch_level: Literal["low","high"] = Field("high", + description="브랜치 레벨\nlow: 국소 위조 흔적 포착\nhigh: 전역 위조 흔적 포착") + + explainer_type: str = Field("eigengradcam", + description = ("선택 가능한 XAI 기법. low: [hirescam, gradcamelementwise, layercam], ""high: [eigengradcam, gradcamplusplus, xgradcam]")) + + display_type: Literal["heatmap", "bbox", "heatmap_bbox"] = Field("heatmap_bbox", + description="시각화 형태. heatmap: 전체 분포, bbox: 위조 의심 영역 사각형, heatmap_bbox: 위조 의심 사각형 내부에 블러 처리된 히트맵을 중첩") + + overlay_ratio: float = Field(0.7, ge=0.5, le=1.0, + description = "Heatmap 투명도 (0: 히트맵만 강조, 1: 원본 이미지 위주)") + + threshold: float = Field(0.9, ge=0.5, le=1.0, + description="contour/bbox 이진화 임계값 (0.0~1.0)") + + aug_smooth: bool = Field(False, + description = "TTA(Test Time Augmentation) 적용 여부. 히트맵을 더 객체 중심적으로 정렬") + + eigen_smooth: bool = Field(False, + description = "PCA 기반 노이즈 제거. 지배적인 패턴만 남김") + + @model_validator(mode="after") + def validate_explainer_for_branch(self) -> "ExplainImageRequest": + branch_allowed = {"low": _LOW_ALLOWED, "high": _HIGH_ALLOWED} + if self.explainer_type not in branch_allowed[self.branch_level]: + raise ValueError( + f"branch_level='{self.branch_level}'에서 허용된 기법: " + f"{sorted(branch_allowed[self.branch_level])} " + f"(입력값: '{self.explainer_type}')" + ) + return self + + @model_validator(mode="after") + def validate_explainer_for_eigen_smooth(self) -> "ExplainImageRequest": + if self.eigen_smooth and self.explainer_type not in _EIGEN_ALLOWED: + raise ValueError( + f"eigen_smooth가 허용된 기법: " + f"{sorted(_EIGEN_ALLOWED)} " + f"(입력값: '{self.explainer_type}')" + ) + return self + +class ExplainFrameRequest(ExplainImageRequest): + """ + [프레임 시각화 요청] 딥페이크 비디오 프레임 위조 흔적 CAM 시각화 파라미터 + + ExplainImageRequest와 동일한 파라미터를 사용하며, 비디오 특정 프레임 단위 시각화에 적용된다. + + - routes: explain (POST /explain/video/{video_id}/frame/{frame_index}) + - services: explain_svc.process_explain_frame_task + """ + pass \ No newline at end of file diff --git a/App/schemas/image_schema.py b/App/schemas/image_schema.py index 919ec5d..7b42ca7 100644 --- a/App/schemas/image_schema.py +++ b/App/schemas/image_schema.py @@ -1,25 +1,57 @@ -from pydantic import BaseModel from datetime import datetime +from pydantic import BaseModel, Field -class BaseMetadata(BaseModel): - image_id: int - image_loc: str - label: str - version_type: str - model_type: str - domain_type: str - created_at: datetime -class InferenceResult(BaseModel): - score : float - face_conf : float - face_ratio : float - face_brightness : float - result_msg : str -class UserHistory(BaseMetadata): - user_id: int - -class UserHistory_indi(UserHistory, InferenceResult): - pass -class ImageData_indi(BaseMetadata, InferenceResult): - user_id : int | None - status : str + +class ImageData(BaseModel): + """ + [공통 메타 / 히스토리 목록] 이미지 분석 기본 식별 정보 + + 목록 응답으로 직접 사용되며, 상세/추론 결과 스키마의 베이스가 된다. + + - routes: image (GET /image/history) + - services: image_svc.get_user_histories + - 상속처: ImageDetailData, ImageResultData + """ + image_id: int = Field(..., description="이미지 분석 레코드 고유 ID (image_result.id)") + image_loc: str = Field(..., description="서버 내 저장된 이미지 파일 경로 (DB 저장 형식, 예: /static/uploads/user@a.com/img_1700000000.png)") + label: str = Field(..., description="딥페이크 판정 결과 라벨 (FAKE / REAL / UNKNOWN)") + version_type: str = Field(..., description="사용된 모델 버전 (v1 / v2)") + model_type: str = Field(..., description="모델 속도/정확도 모드 (fast / pro)") + domain_type: str = Field(..., description="얼굴 도메인 타입 (서양인 / 동양인)") + created_at: datetime = Field(..., description="분석 요청 생성 시각 (UTC)") + + +class ImageInference(BaseModel): + """ + [추론 상세 결과] 딥페이크 분석 수치 결과 + + - 상속 베이스 클래스 (직접 응답 X) + - 상속처: ImageDetailData, ImageResultData + - services: image_svc.get_user_history, image_svc.get_image_result + """ + score: float = Field(..., description="딥페이크 확률 점수 (0.0~1.0, 0.5 이상이면 FAKE 판정). 분석 실패 시 -1.0") + face_conf: float = Field(..., description="얼굴 탐지 신뢰도 (0.0~1.0). 분석 실패 시 -1.0") + face_ratio: float = Field(..., description="이미지 내 얼굴이 차지하는 면적 비율 (0.0~1.0). 분석 실패 시 -1.0") + face_brightness: float = Field(..., description="얼굴 영역 평균 밝기 값. 분석 실패 시 -1.0") + result_msg: str = Field(..., description="분석 결과에 대한 상세 메시지 (성공/경고/실패 사유)") + + +class ImageDetailData(ImageData, ImageInference): + """ + [히스토리 상세] 회원 개별 이미지 분석 상세 결과 + + - routes: image (GET /image/history/{image_id}, DELETE /image/history/{image_id} - 내부 조회) + - services: image_svc.get_user_history + """ + status: str + + +class ImageResultData(ImageData, ImageInference): + """ + [추론 결과 조회] 분석 진행 상태 + 최종 결과 (회원/비회원 공통) + + - routes: inference (GET /inference/image/{image_id}) + - services: image_svc.get_image_result + """ + user_id: int | None = Field(None, description="유저 ID. 비회원 분석 요청의 경우 None") + status: str = Field(..., description="분석 진행 상태 (PENDING / SUCCESS / WARNING / FAILED)", examples=["SUCCESS"]) \ No newline at end of file diff --git a/App/schemas/video_schema.py b/App/schemas/video_schema.py index 7da9f05..1b9c443 100644 --- a/App/schemas/video_schema.py +++ b/App/schemas/video_schema.py @@ -1,50 +1,69 @@ -from pydantic import BaseModel from datetime import datetime +from pydantic import BaseModel, Field -# [히스토리 목록] GET /video/history -# 사용자 전체 비디오 히스토리 조회 class VideoData(BaseModel): - id: int - user_id : int | None - video_loc : str - status : str - label : str - version_type : str - model_type : str - domain_type : str - created_at : datetime + """ + [히스토리 목록] 사용자 전체 비디오 히스토리 조회 + + - routes: video (GET /video/history) + - services: video_svc.get_user_histories + """ + id: int = Field(..., description="비디오 분석 레코드 고유 ID (video_result.id)") + user_id: int | None = Field(None, description="분석 요청 유저 ID. 비회원의 경우 None") + video_loc: str = Field(..., description="서버 내 저장된 비디오 파일 경로 (예: /static/uploads/user@a.com/vid_1700000000.mp4)") + status: str = Field(..., description="분석 진행 상태 (PENDING / SUCCESS / WARNING / FAILED)") + label: str = Field(..., description="딥페이크 판정 결과 라벨 (FAKE / REAL / UNKNOWN)") + version_type: str = Field(..., description="사용된 모델 버전 (v1 / v2)") + model_type: str = Field(..., description="모델 속도/정확도 모드 (fast / pro)") + domain_type: str = Field(..., description="얼굴 도메인 타입 (서양인 / 동양인)") + created_at: datetime = Field(..., description="분석 요청 생성 시각 (UTC)") -# [히스토리 상세 / 추론 결과] GET /video/history/{video_id}, GET /inference/video/{video_id} -# 개별 비디오 상세 조회 + 추론 결과값 포함 class VideoDetailData(VideoData): + """ + [히스토리 상세 / 추론 결과] 개별 비디오 상세 + 추론 수치 결과 - score : float - face_conf : float - face_ratio : float - face_brightness : float - result_msg : str + - routes: video (GET /video/history/{video_id}), inference (GET /inference/video/{video_id}) + - services: video_svc.get_user_history, video_svc.get_video_result + """ + score: float = Field(..., description="비디오 전체 평균 딥페이크 확률 (0.0~1.0). 실패 시 -1.0") + face_conf: float = Field(..., description="평균 얼굴 탐지 신뢰도. 실패 시 -1.0") + face_ratio: float = Field(..., description="평균 얼굴 면적 비율. 실패 시 -1.0") + face_brightness: float = Field(..., description="평균 얼굴 영역 밝기. 실패 시 -1.0") + result_msg: str = Field(..., description="분석 결과 상세 메시지") -# [프레임별 추론 결과] GET /inference/video/{video_id}/detail 응답 내부 -# video_frame_result 테이블 row 단위 매핑 class VideoFrameData(BaseModel): - frame_index: int - frame_time: float - score: float - face_conf: float - face_ratio: float - face_brightness: float + """ + [프레임별 추론 결과] 비디오 프레임 단위 분석 결과 -# [비디오 메타 정보] GET /inference/video/{video_id}/detail 응답 내부 -# video_meta_result 테이블 row 단위 매핑 + - routes: inference (GET /inference/video/{video_id}/detail 응답 내부) + - services: video_svc.get_video_frame_result, save_video_frame_result + """ + frame_index: int = Field(..., description="비디오 내 프레임 인덱스 (0부터 시작)") + frame_time: float = Field(..., description="해당 프레임의 영상 내 재생 시점 (초 단위)") + score: float = Field(..., description="해당 프레임의 딥페이크 확률 점수 (0.0~1.0)") + face_conf: float = Field(..., description="해당 프레임 얼굴 탐지 신뢰도") + face_ratio: float = Field(..., description="해당 프레임 얼굴 면적 비율") + face_brightness: float = Field(..., description="해당 프레임 얼굴 영역 밝기") + class VideoMetaData(BaseModel): - fps: float - total_frames: int # 전체 프레임 수 - num_sampled: int # 샘플링 대상 프레임 수 - num_extracted: int # 실제 추출된 프레임 수 - num_detected: int # 얼굴 탐지 성공 프레임 수 + """ + [비디오 메타 정보] 비디오 전체 프레임 처리 통계 + + - routes: inference (GET /inference/video/{video_id}/detail 응답 내부) + - services: video_svc.get_video_meta_result, save_video_meta_result + """ + fps: float = Field(..., description="비디오의 초당 프레임 수 (Frames Per Second)") + total_frames: int = Field(..., description="비디오 전체 프레임 수") + num_sampled: int = Field(..., description="추론을 위해 샘플링한 프레임 수") + num_extracted: int = Field(..., description="실제로 추출에 성공한 프레임 수") + num_detected: int = Field(..., description="얼굴 탐지에 성공한 프레임 수 (score 산출 성공)") -# [상세 분석 최종 응답] GET /inference/video/{video_id}/{detail} -# 로그인 유저 전용 (get_session_user_prt) class VideoDetailResponse(BaseModel): - meta: VideoMetaData - frames: list[VideoFrameData] \ No newline at end of file + """ + [상세 분석 최종 응답] 비디오 상세 분석 화면용 통합 응답 + + 사용처: + - routes: inference (GET /inference/video/{video_id}/detail) - 로그인 유저 전용 + """ + meta: VideoMetaData = Field(..., description="비디오 메타 정보 (FPS, 프레임 수 등)") + frames: list[VideoFrameData] = Field(..., description="프레임별 상세 분석 결과 리스트 (frame_index 오름차순)") \ No newline at end of file diff --git a/App/services/auth_svc.py b/App/services/auth_svc.py index 5c28905..139761f 100644 --- a/App/services/auth_svc.py +++ b/App/services/auth_svc.py @@ -1,4 +1,3 @@ -from datetime import datetime, timedelta, timezone from fastapi import status from fastapi.exceptions import HTTPException from passlib.context import CryptContext @@ -9,14 +8,43 @@ # 비밀번호 암호화 및 검증 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -def get_hashed_password(password: str): +def get_hashed_password(password: str) -> str: + """평문 비밀번호를 bcrypt 알고리즘으로 해싱한다. + + Args: + password (str): 사용자가 입력한 평문 비밀번호. + + Returns: + str: bcrypt 해싱된 비밀번호 문자열. + """ return pwd_context.hash(password) -def verify_password(plain_password: str, hashed_password: str): +def verify_password(plain_password: str, hashed_password: str) -> bool: + """평문 비밀번호와 해시된 비밀번호의 일치 여부를 검증한다. + + Args: + plain_password (str): 사용자가 입력한 평문 비밀번호. + hashed_password (str): DB에 저장된 bcrypt 해시 비밀번호. + + Returns: + bool: 비밀번호 일치 여부. + """ return pwd_context.verify(plain_password, hashed_password) -# 회원가입 시, 이미 존재하는 이메일과 중복 확인 -async def get_user_by_email(conn: Connection, email: str): +async def get_user_by_email(conn: Connection, email: str) -> UserData | None: + """이메일로 사용자를 조회해 중복 가입 여부를 확인한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + email (str): 조회할 이메일 주소. + + Returns: + UserData | None: 이메일이 존재하면 UserData 반환, 없으면 None. + + Raises: + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 예외 발생 시. + """ try: query = f""" SELECT id, name, email from user @@ -45,8 +73,18 @@ async def get_user_by_email(conn: Connection, email: str): raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 서비스 오류가 발생하였습니다.") -# 실제 DB에 회원 정보(이름, 이메일, 해싱된 패스워드) -async def register_user(conn: Connection, name: str, email:str, hashed_password: str): +async def register_user(conn: Connection, name: str, email:str, hashed_password: str) -> None: + """신규 사용자 정보를 DB에 저장한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + name (str): 사용자 이름. + email (str): 사용자 이메일 주소. + hashed_password (str): bcrypt 해시 처리된 비밀번호. + + Raises: + HTTPException 400: DB INSERT 실패 또는 잘못된 요청 데이터. + """ try: query = f""" INSERT INTO user(name, email, hashed_password) @@ -64,9 +102,19 @@ async def register_user(conn: Connection, name: str, email:str, hashed_password: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="요청데이터가 제대로 전달되지 않았습니다") -# 해당 이메일 가진 회원정보 가져오기 -async def authenticate_user(conn: Connection, email: str): - """로그인 시 이메일을 검증하여 일치하면 유저 정보를 반환합니다.""" +async def authenticate_user(conn: Connection, email: str) -> UserDataPASS | bool: + """로그인 시 이메일로 사용자를 조회해 인증 정보를 반환한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + email (str): 로그인 요청 이메일. + + Returns: + UserDataPASS | bool: 사용자가 존재하면 UserDataPASS 반환, 없으면 False. + + Raises: + HTTPException 400: DB 쿼리 중 SQLAlchemy 오류 발생 시. + """ try: query = """ SELECT id, name, email, hashed_password FROM user diff --git a/App/services/explain_svc.py b/App/services/explain_svc.py new file mode 100644 index 0000000..5b3cbcf --- /dev/null +++ b/App/services/explain_svc.py @@ -0,0 +1,290 @@ +import cv2 +import asyncio +import numpy as np +from fastapi import status +from fastapi.exceptions import HTTPException +from sqlalchemy import Connection +from services import image_svc, inference_svc +from explainability import ( + CAMExplainer, + HiResCAMExplainer, GradCAMElementWiseExplainer, LayerCAMExplainer, + EigenGradCAMExplainer, GradCAMPlusPlusExplainer, XGradCAMExplainer, +) +from celery_app import celery_app + +# LOW branch: 국소 위조 흔적 포착 / HIGH branch: 전역 위조 흔적 포착 +EXPLAINER_REGISTRY = { + "hirescam": HiResCAMExplainer, # LOW branch + "gradcamelementwise": GradCAMElementWiseExplainer, # LOW branch + "layercam": LayerCAMExplainer, # LOW branch + "eigengradcam": EigenGradCAMExplainer, # HIGH branch + "gradcamplusplus": GradCAMPlusPlusExplainer, # HIGH branch + "xgradcam": XGradCAMExplainer, # HIGH branch +} + +_explainer_cache: dict = {} + +def _extract_face_from_frame(video_path: str, frame_time: float, explainer: CAMExplainer) -> np.ndarray: + """비디오의 특정 시간대 프레임에서 얼굴 영역을 추출해 크롭된 배열을 반환한다. + + Args: + video_path (str): 비디오 파일 경로. + frame_time (float): 프레임을 추출할 시간 (초 단위). + explainer (CAMExplainer): 얼굴 감지 및 크롭에 사용할 CAMExplainer 인스턴스. + + Returns: + np.ndarray: 크롭된 얼굴 이미지 배열 (RGB). + """ + cap = cv2.VideoCapture(video_path) + cap.set(cv2.CAP_PROP_POS_MSEC, frame_time * 1000) + ret, frame = cap.read() + cap.release() + + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + bbox = explainer._get_face_bbox(frame_rgb) + + return explainer._crop_face(frame_rgb, bbox[:4]) + +def _get_or_create_explainer(model_name: str, dataset: str, explain_req_dict: dict) -> CAMExplainer: + """캐시에서 CAMExplainer를 조회하거나, 없으면 새로 생성해 캐싱 후 반환한다. + + 캐시 키는 (model_name, dataset, explainer_type, branch_level) 4개 파라미터로 구성된다. + 추론 시점 파라미터(aug_smooth, threshold 등)는 키에서 제외해 메모리 사용을 최소화한다. + + Args: + model_name (str): 추론에 사용할 모델 이름. + dataset (str): 모델 학습에 사용된 데이터셋 이름. + explain_req_dict (dict): 시각화 요청 파라미터 딕셔너리. + - explainer_type (str): EXPLAINER_REGISTRY 키값. + - branch_level (str): 모델 브랜치 레벨 ("LOW" | "HIGH"). + + Returns: + CAMExplainer: 캐시된 또는 새로 생성된 CAMExplainer 인스턴스. + """ + cache_key = (model_name, dataset, explain_req_dict["explainer_type"], explain_req_dict["branch_level"]) + + if cache_key not in _explainer_cache: + _explainer_cache[cache_key] = EXPLAINER_REGISTRY[explain_req_dict["explainer_type"]]( + model_name = model_name, + dataset = dataset, + branch_level = explain_req_dict["branch_level"], + ) + return _explainer_cache[cache_key] + +def _run_visualization(explainer: CAMExplainer, image_path: str, category: int, explain_req_dict: dict) -> np.ndarray: + """이미지 경로를 받아 display_type에 따라 시각화 이미지를 생성한다. + + Args: + explainer (CAMExplainer): 시각화에 사용할 CAMExplainer 인스턴스. + image_path (str): 입력 이미지 파일 경로. + category (int): 타겟 클래스 인덱스 (위조: 1, 정상: 0). + explain_req_dict (dict): 시각화 요청 파라미터 딕셔너리. + - display_type (str): "heatmap" | "bbox" | "heatmap_bbox" + - overlay_ratio (float): 히트맵 오버레이 시 원본 이미지 가중치. + - threshold (float | str): CAM 이진화 임계값. + - aug_smooth (bool): Test-time augmentation 가동 여부. + - eigen_smooth (bool): PCA 기반 노이즈 제거 가동 여부. + + Returns: + np.ndarray: 시각화가 적용된 얼굴 이미지 (RGB, np.uint8). + """ + if explain_req_dict["display_type"] == "heatmap": + return explainer.display_heatmap_on_image(image_path, image_weight=explain_req_dict["overlay_ratio"], threshold=explain_req_dict["threshold"], + category=category, aug_smooth=explain_req_dict["aug_smooth"], eigen_smooth=explain_req_dict["eigen_smooth"]) + elif explain_req_dict["display_type"] == "bbox": + return explainer.display_bbox_on_image(image_path, threshold=explain_req_dict["threshold"], + category=category, aug_smooth=explain_req_dict["aug_smooth"], eigen_smooth=explain_req_dict["eigen_smooth"]) + else: # display_type == "heatmap_bbox" + return explainer.display_heatmap_bbox_on_image(image_path, image_weight=explain_req_dict["overlay_ratio"], threshold=explain_req_dict["threshold"], + category=category, aug_smooth=explain_req_dict["aug_smooth"], eigen_smooth=explain_req_dict["eigen_smooth"]) + +def _run_visualization_from_array(explainer: CAMExplainer, face: np.ndarray, category: int, explain_req_dict: dict) -> np.ndarray: + """크롭된 얼굴 배열을 받아 display_type에 따라 시각화 이미지를 생성한다. + + Args: + explainer (CAMExplainer): 시각화에 사용할 CAMExplainer 인스턴스. + face (np.ndarray): 전처리(크롭)가 완료된 얼굴 이미지 배열 (RGB). + category (int): 타겟 클래스 인덱스 (위조: 1, 정상: 0). + explain_req_dict (dict): 시각화 요청 파라미터 딕셔너리. (_run_visualization과 동일) + + Returns: + np.ndarray: 시각화가 적용된 얼굴 이미지 (RGB, np.uint8). + """ + if explain_req_dict["display_type"] == "heatmap": + return explainer.display_heatmap_from_array(face, image_weight=explain_req_dict["overlay_ratio"], threshold=explain_req_dict["threshold"], + category=category, aug_smooth=explain_req_dict["aug_smooth"], eigen_smooth=explain_req_dict["eigen_smooth"]) + elif explain_req_dict["display_type"] == "bbox": + return explainer.display_bbox_from_array(face, threshold=explain_req_dict["threshold"], + category=category, aug_smooth=explain_req_dict["aug_smooth"], eigen_smooth=explain_req_dict["eigen_smooth"]) + else: # display_type == "heatmap_bbox" + return explainer.display_heatmap_bbox_from_array(face, image_weight=explain_req_dict["overlay_ratio"], threshold=explain_req_dict["threshold"], + category=category, aug_smooth=explain_req_dict["aug_smooth"], eigen_smooth=explain_req_dict["eigen_smooth"]) + +@celery_app.task(name="process_explain_image_task") +def process_explain_image_task(user_email: str, + version_type: str, + domain_type: str, + image_loc: str, + image_id: int, + category: int, + explain_req_dict: dict) -> dict: + """딥페이크 이미지 위조 흔적 시각화를 처리하는 Celery 비동기 태스크. + + CAMExplainer로 시각화 이미지를 생성하고 서버에 저장한다. + Celery 워커(동기) 내에서 asyncio 이벤트 루프를 직접 구동해 비동기 로직을 실행한다. + + Args: + user_email (str): 요청 사용자 이메일 (저장 경로 구분용). + version_type (str): 모델 버전 타입 (MODEL_CONFIG 키값). + domain_type (str): 탐지 도메인 타입 (MODEL_CONFIG 키값). + image_loc (str): 분석 대상 이미지의 서버 저장 경로 (DB 기준, '/'로 시작). + image_id (int): 이미지 레코드 PK. + category (int): 타겟 클래스 인덱스 (위조: 1, 정상: 0). + explain_req_dict (dict): 시각화 요청 파라미터 딕셔너리. + + Returns: + dict: + - status (str): "SUCCESS" | "FAILED" + - message (str): 처리 결과 메세지. + - cam_loc (str | None): 저장된 시각화 이미지 경로. 실패 시 None. + + Note: + 생성된 시각화 파일은 태스크 완료 60초 후 자동 삭제된다. + """ + async def run_explain(): + cam_loc = None + try: + model_name, dataset = inference_svc.MODEL_CONFIG[version_type][explain_req_dict["model_type"]][domain_type] + + explainer = _get_or_create_explainer(model_name, dataset, explain_req_dict) + image_path = "." + image_loc + + # 시각화 이미지 생성 시작 + try: + image = _run_visualization(explainer, image_path, category, explain_req_dict) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="딥페이크 이미지 위조 흔적을 생성하는 중 오류가 발생하였습니다" + ) + + # 생성된 시각화 이미지 파일 저장 + try: + cam_loc = await image_svc.upload_image_cam(user_email, image_id, image) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="딥페이크 이미지 위조 흔적 파일을 저장하는 중 오류가 발생했습니다." + ) + + return {"status": "SUCCESS", + "message": "딥페이크 이미지 위조 흔적 시각화가 성공적으로 이루어졌습니다", + "cam_loc": cam_loc} + + except HTTPException as e: + print(e.detail) + return {"status": "FAILED", "message": str(e.detail)} + + except Exception as e: + print(str(e)) + return {"status": "FAILED", "message": str(e)} + + finally: + # 임시 저장된 시각화 이미지 파일 삭제 + if cam_loc: + image_svc.cleanup_image_cam.apply_async(args=[cam_loc], countdown=60) + + # 동기식 Celery 워커 내 비동기 이벤트 루프 구동 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(run_explain()) + finally: + loop.close() + +@celery_app.task(name="process_explain_frame_task") +def process_explain_frame_task(user_email: str, + version_type: str, + domain_type: str, + video_loc: str, + video_id: int, + category: int, + frame_time: float, + explain_req_dict: dict) -> dict: + """딥페이크 비디오 특정 프레임의 위조 흔적 시각화를 처리하는 Celery 비동기 태스크. + + 비디오에서 프레임을 추출하고 얼굴을 크롭한 뒤 CAMExplainer로 시각화 이미지를 생성해 저장한다. + Celery 워커(동기) 내에서 asyncio 이벤트 루프를 직접 구동해 비동기 로직을 실행한다. + + Args: + user_email (str): 요청 사용자 이메일 (저장 경로 구분용). + version_type (str): 모델 버전 타입 (MODEL_CONFIG 키값). + domain_type (str): 탐지 도메인 타입 (MODEL_CONFIG 키값). + video_loc (str): 분석 대상 비디오의 서버 저장 경로 (DB 기준, '/'로 시작). + video_id (int): 비디오 레코드 PK. + category (int): 타겟 클래스 인덱스 (위조: 1, 정상: 0). + frame_time (float): 시각화할 프레임의 타임스탬프 (초 단위). + explain_req_dict (dict): 시각화 요청 파라미터 딕셔너리. + + Returns: + dict: + - status (str): "SUCCESS" | "FAILED" + - message (str): 처리 결과 메세지. + - cam_loc (str | None): 저장된 시각화 이미지 경로. 실패 시 None. + + Note: + 생성된 시각화 파일은 태스크 완료 60초 후 자동 삭제된다. + """ + async def run_explain(): + cam_loc = None + try: + model_name, dataset = inference_svc.MODEL_CONFIG[version_type][explain_req_dict["model_type"]][domain_type] + video_path = "." + video_loc + + explainer = _get_or_create_explainer(model_name, dataset, explain_req_dict) + + face = _extract_face_from_frame(video_path, frame_time, explainer) + + # 비디오 프레임 시각화 생성 시작 + try: + image = _run_visualization_from_array(explainer, face, category, explain_req_dict) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="딥페이크 비디오 프레임 위조 흔적을 생성하는 중 오류가 발생하였습니다" + ) + + # 비디오 프레임 시각화 파일 저장 + try: + cam_loc = await image_svc.upload_frame_cam(user_email, video_id, frame_time, image) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="딥페이크 비디오 프레임 위조 흔적 파일을 저장하는 중 오류가 발생했습니다." + ) + + return {"status": "SUCCESS", + "message": "딥페이크 비디오 프레임 위조 흔적 시각화가 성공적으로 이루어졌습니다", + "cam_loc": cam_loc} + + except HTTPException as e: + print(e.detail) + return {"status": "FAILED", "message": str(e.detail)} + + except Exception as e: + print(str(e)) + return {"status": "FAILED", "message": str(e)} + + finally: + # 임시 저장된 비디오 프레임 시각화 파일 삭제 + if cam_loc: + image_svc.cleanup_image_cam.apply_async(args=[cam_loc], countdown=60) + + # 동기식 Celery 워커 내 비동기 이벤트 루프 구동 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(run_explain()) + finally: + loop.close() + diff --git a/App/services/image_svc.py b/App/services/image_svc.py index 9a92d68..21f3d4e 100644 --- a/App/services/image_svc.py +++ b/App/services/image_svc.py @@ -1,47 +1,55 @@ import os -import asyncio import time +import cv2 +import numpy as np import aiofiles as aio +import asyncio from dotenv import load_dotenv - from fastapi import UploadFile, status from fastapi.exceptions import HTTPException from sqlalchemy import text, Connection -from sqlalchemy.exc import SQLAlchemyError, DBAPIError -from schemas.image_schema import UserHistory, UserHistory_indi, ImageData_indi +from sqlalchemy.exc import SQLAlchemyError +from schemas.image_schema import ImageData, ImageDetailData, ImageResultData from db.database import celery_db_conn from celery_app import celery_app load_dotenv() -UPLOAD_DIR = os.getenv("UPLOAD_DIR") +UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./static/uploads") +EXPLAIN_UPLOAD_DIR = os.getenv("EXPLAIN_UPLOAD_DIR", "./static/explain") + +os.makedirs(EXPLAIN_UPLOAD_DIR, exist_ok=True) +os.makedirs(UPLOAD_DIR, exist_ok=True) -# 사용자 업로드 이미지 서버 내 저장 (회원/비회원 공통) async def upload_image(user_email: str | None, imagefile: UploadFile) -> str: + """업로드된 이미지를 서버에 저장하고 DB 저장용 경로를 반환한다. 회원/비회원 공통. + + Args: + user_email (str | None): 로그인 사용자 이메일. 비회원이면 None. + imagefile (UploadFile): FastAPI 업로드 파일 객체. + + Returns: + str: 저장된 이미지의 DB 기준 경로 ('/'로 시작, '\\' 정규화됨). + + Raises: + HTTPException 500: 파일 쓰기 또는 예기치 못한 오류 발생 시. + """ try: - # 1. 사용자별 하위 디렉토리 결정 + # 사용자별 하위 디렉토리 결정 sub_dir = user_email if user_email else "anonymous" user_dir = os.path.join(UPLOAD_DIR, sub_dir) - # 3. 디렉토리 존재 확인 및 생성 - if not os.path.exists(user_dir): - try: - os.makedirs(user_dir, exist_ok=True) - except OSError as e: - print(f"[Storage Error] 디렉토리 생성 실패: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="서버 내 저장 공간을 준비하지 못했습니다." - ) - - # 4. 파일명 중복 방지 (파일명_타임스탬프.확장자) + # 디렉토리 존재 확인 및 생성 + os.makedirs(user_dir, exist_ok=True) + + # 파일명 중복 방지 (파일명_타임스탬프.확장자) filename_only, ext = os.path.splitext(imagefile.filename) upload_filename = f"{filename_only}_{int(time.time())}{ext}" upload_image_loc = os.path.join(user_dir, upload_filename) - # 5. 비동기 이미지 저장 (Chunk 단위 읽기) + # 비동기 이미지 저장 (Chunk 단위 읽기) try: async with aio.open(upload_image_loc, "wb") as outfile: while content := await imagefile.read(1024 * 1024): @@ -53,8 +61,6 @@ async def upload_image(user_email: str | None, imagefile: UploadFile) -> str: detail="이미지 파일을 저장하는 중 오류가 발생했습니다." ) - print(f"Upload Succeeded: {upload_image_loc}") - # 6. DB 저장용 경로 반환 return upload_image_loc[1:].replace("\\", "/") @@ -67,156 +73,204 @@ async def upload_image(user_email: str | None, imagefile: UploadFile) -> str: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="이미지 업로드 과정에서 예상치 못한 오류가 발생했습니다.") -# 사용자 업로드 이미지 서버 내 삭제 +async def upload_image_cam(user_email: str, image_id: int, image: np.ndarray) -> str: + """CAM 시각화 이미지를 서버에 저장하고 DB 저장용 경로를 반환한다. 회원 전용. + + Args: + user_email (str): 요청 사용자 이메일 (저장 경로 구분용). + image_id (int): 이미지 레코드 PK (파일명 구성에 사용). + image (np.ndarray): 저장할 시각화 이미지 배열 (RGB). + + Returns: + str: 저장된 CAM 이미지의 DB 기준 경로 ('/'로 시작). + + Raises: + Exception: 파일 쓰기 실패 시 그대로 re-raise. + """ + user_dir = os.path.join(EXPLAIN_UPLOAD_DIR, user_email) + os.makedirs(user_dir, exist_ok=True) + + cam_filename = f"{image_id}_{int(time.time())}.png" + cam_loc = os.path.join(user_dir, cam_filename) + + image_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + _, buf = cv2.imencode(".png", image_bgr) + + try: + async with aio.open(cam_loc, "wb") as outfile: + await outfile.write(buf.tobytes()) + except Exception as e: + raise e + + return cam_loc[1:].replace("\\", "/") + +async def upload_frame_cam(user_email: str, video_id: int, frame_time: float, image: np.ndarray) -> str: + """비디오 프레임 CAM 시각화 이미지를 서버에 저장하고 DB 저장용 경로를 반환한다. 회원 전용. + + Args: + user_email (str): 요청 사용자 이메일 (저장 경로 구분용). + video_id (int): 비디오 레코드 PK (파일명 구성에 사용). + frame_time (float): 시각화한 프레임의 타임스탬프 (초 단위, 파일명 구성에 사용). + image (np.ndarray): 저장할 시각화 이미지 배열 (RGB). + + Returns: + str: 저장된 CAM 이미지의 DB 기준 경로 ('/'로 시작). + + Raises: + Exception: 파일 쓰기 실패 시 그대로 re-raise. + """ + user_dir = os.path.join(EXPLAIN_UPLOAD_DIR, user_email) + os.makedirs(user_dir, exist_ok=True) + + cam_filename = f"v{video_id}_t{frame_time}_{int(time.time())}.png" + cam_loc = os.path.join(user_dir, cam_filename) + + image_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + _, buf = cv2.imencode(".png", image_bgr) + + try: + async with aio.open(cam_loc, "wb") as outfile: + await outfile.write(buf.tobytes()) + except Exception as e: + raise e + + return cam_loc[1:].replace("\\", "/") + async def delete_image(image_loc: str): + """서버에서 이미지 물리 파일을 삭제한다. + + 파일이 존재하지 않으면 경고 로그만 출력하고 정상 종료한다. + + Args: + image_loc (str): 삭제할 이미지의 DB 기준 경로 ('/'로 시작). + + Raises: + HTTPException 500: 파일 삭제 중 예기치 못한 오류 발생 시. + """ try: file_path = "." + image_loc if os.path.exists(file_path): os.remove(file_path) - print(f"File removed: {file_path}") else: print(f"File not found: {file_path}") - except Exception as e: - print(f"{e}") + except Exception: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다." ) -# 사용자 전체 히스토리 조회 (image_result 테이블 반영) -async def get_user_histories(conn: Connection, user_id: int): - try: - # SQL에 맞춰 테이블명과 컬럼 변경 - query = (""" - SELECT id, user_id, image_loc, label, version_type, model_type, domain_type, created_at - FROM image_result - WHERE user_id = :user_id - ORDER BY created_at DESC; - """) - stmt = text(query) - bind_stmt = stmt.bindparams(user_id = user_id) - result = await conn.execute(bind_stmt) - user_histories = [UserHistory( - image_id = row.id, - user_id = row.user_id, - image_loc = row.image_loc, - label = row.label, - version_type = row.version_type, - model_type = row.model_type, - domain_type = row.domain_type, - created_at = row.created_at - ) - for row in result] - - result.close() +async def delete_image_db(conn: Connection, image_id: int): + """DB에서 이미지 레코드를 삭제한다. - - return user_histories - - except SQLAlchemyError as e: - print(f"히스토리 조회 실패: {e}") - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + image_id (int): 삭제할 이미지 레코드 PK. - except Exception as e: - print(e) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") - -# 사용자 개별 히스토리 조회 -async def get_user_history(conn: Connection, image_id: int): + Raises: + HTTPException 404: 해당 image_id 레코드가 없을 때. + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ try: - query = """ - SELECT id, user_id, image_loc, label, score, face_conf, face_ratio, face_brightness, version_type, model_type, domain_type, result_msg, created_at - FROM image_result - WHERE id = :image_id; - """ - stmt = text(query) - bind_stmt = stmt.bindparams(image_id=image_id) - result = await conn.execute(bind_stmt) - - row = result.fetchone() - if row is None: - return None - - user_history = UserHistory_indi( - image_id = row.id, - user_id = row.user_id, - image_loc = row.image_loc, - label = row.label, - score = row.score, - face_conf = row.face_conf, - face_ratio = row.face_ratio, - face_brightness = row.face_brightness, - version_type = row.version_type, - model_type = row.model_type, - domain_type = row.domain_type, - result_msg = row.result_msg, - created_at = row.created_at - ) - - result.close() - - return user_history - + delete_query = text("DELETE FROM image_result WHERE id = :image_id") + result = await conn.execute(delete_query, {"image_id": image_id}) + + if result.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"해당 이미지 id {image_id}는(은) 존재하지 않아 삭제할 수 없습니다.") + + await conn.commit() + except SQLAlchemyError as e: - print(f"히스토리 조회 실패: {e}") + await conn.rollback() raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") except HTTPException: raise - + except Exception as e: - print(e) + await conn.rollback() raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") -# 비회원 데이터 5분 후 자동 삭제 태스크 @celery_app.task(name="cleanup_anonymous_image") def cleanup_anonymous_image(image_id: int, image_loc: str): + """비회원 이미지 DB 레코드 및 물리 파일을 삭제하는 Celery 태스크. + + DB 삭제와 파일 삭제는 독립적으로 처리되어 한쪽 실패가 다른쪽에 영향을 주지 않는다. + + Args: + image_id (int): 삭제할 이미지 레코드 PK. + image_loc (str): 삭제할 이미지의 DB 기준 경로. + """ loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: async def _delete(): async with celery_db_conn() as conn: - await delete_image_db(conn, image_id) - await delete_image(image_loc) - print(f"[Cleanup] 비회원 이미지 삭제 완료 - image_id: {image_id}, image_loc: {image_loc}") + success = True + try: + await delete_image_db(conn, image_id) + except Exception as e: + print(f"[Cleanup] 이미지 DB 삭제 실패 - image_id: {image_id}, error: {e}") + success=False + try: + await delete_image(image_loc) + except Exception as e: + print(f"[Cleanup] 이미지 파일 삭제 실패 - image_loc: {image_loc}, error: {e}") + success=False + + if success: + print(f"[Cleanup] 비회원 이미지 삭제 프로세스 완료 - image_id: {image_id}, image_loc: {image_loc}") + loop.run_until_complete(_delete()) - except Exception as e: - print(f"[Cleanup Error] 비회원 이미지 삭제 실패 - image_id: {image_id}, error: {e}") + finally: loop.close() +@celery_app.task(name="cleanup_image_cam") +def cleanup_image_cam(cam_loc: str): + """CAM 시각화 파일을 삭제하는 Celery 태스크. -# 이미지 DB 레코드 및 물리 파일 완전 삭제 -async def delete_image_db(conn: Connection, image_id: int): - try: - delete_query = text("DELETE FROM image_result WHERE id = :image_id") - result = await conn.execute(delete_query.bindparams(image_id=image_id)) - - if result.rowcount == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"해당 이미지 id {id}는(은) 존재하지 않아 삭제할 수 없습니다.") - - await conn.commit() + 이미지/비디오 프레임 시각화 파일 모두 이 태스크로 정리한다. + Args: + cam_loc (str): 삭제할 CAM 시각화 파일의 DB 기준 경로. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + async def _delete(): + try: + await delete_image(cam_loc) + print(f"[Cleanup] 이미지 시각화 파일 삭제 완료 - cam_loc: {cam_loc}") + except Exception as e: + print(f"[Cleanup] 이미지 시각화 파일 삭제 실패 - cam_loc: {cam_loc}, error: {e}") + + loop.run_until_complete(_delete()) - except SQLAlchemyError as e: - print(e) - await conn.rollback() - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") - - except HTTPException: - raise + finally: + loop.close() - except Exception as e: - print(e) - await conn.rollback() - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") - -# 빈 이미지 DB 생성 이후, image_id 반환(접수 완료) async def register_image_result(conn: Connection, user_id: int | None, image_loc: str, version_type: str, model_type: str, domain_type: str): + """PENDING 상태의 빈 이미지 레코드를 DB에 생성하고 image_id를 반환한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + user_id (int | None): 로그인 사용자 ID. 비회원이면 None. + image_loc (str): 업로드된 이미지의 DB 기준 경로. + version_type (str): 모델 버전 타입. + model_type (str): 추론 모드 ("fast" | "pro"). + domain_type (str): 탐지 도메인. + + Returns: + int: 생성된 이미지 레코드의 PK (auto_increment). + + Raises: + HTTPException 400: DB INSERT 실패 시. 업로드된 물리 파일도 함께 롤백 삭제된다. + """ try: query = """ INSERT INTO image_result ( @@ -248,14 +302,24 @@ async def register_image_result(conn: Connection, user_id: int | None, image_loc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="요청데이터가 제대로 전달되지 않았습니다") -# 이미지 메타데이터 + 추론 결과값 DB에 저장 async def update_image_result(conn: Connection, image_id: int, analysis: dict, result_msg: str, status: str): - + """추론 완료 후 이미지 레코드에 분석 결과를 업데이트한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + image_id (int): 업데이트할 이미지 레코드 PK. + analysis (dict): 추론 결과값. prob, face_conf, face_ratio, face_brightness 포함. + result_msg (str): 처리 결과 메세지. + status (str): 추론 상태 ("success" | "warning" | "failed"). + + Raises: + SQLAlchemyError: DB 업데이트 실패 시 그대로 re-raise. + """ # status 종류: success, warning, failed # success: 이미지 추론 성공 # warning: 이미지 추론 과정 PredictError 발생 - # failed: 이미지 추로 과정 알수 없는 오류 발생 + # failed: 이미지 추론 과정 알수 없는 오류 발생 if status == 'success': label = "FAKE" if analysis["prob"] > 0.5 else "REAL" @@ -292,12 +356,29 @@ async def update_image_result(conn: Connection, image_id: int, analysis: dict, except SQLAlchemyError as e: await conn.rollback() raise e - -# 이미지 결과 값 가져오기 -async def get_image_result(conn: Connection, - image_id: int): + +async def get_image_result(conn: Connection, image_id: int): + """image_id로 이미지 분석 결과를 조회해 반환한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + image_id (int): 조회할 이미지 레코드 PK. + + Returns: + ImageData_indi: 이미지 분석 결과 스키마 객체. + + Raises: + HTTPException 404: 해당 image_id 레코드가 없을 때. + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ try: - query = text("SELECT * FROM image_result WHERE id = :image_id") + query = text(""" + SELECT id, user_id, image_loc, status, label, score, face_conf, face_ratio, + face_brightness, version_type, model_type, domain_type, result_msg, created_at + FROM image_result + WHERE id = :image_id + """) result = await conn.execute(query, {"image_id": image_id}) if result.rowcount == 0: @@ -305,7 +386,7 @@ async def get_image_result(conn: Connection, detail=f"요청하신 이미지 분석 결과(ID: {image_id})를 찾을 수 없습니다. ID를 다시 확인해주세요.") row = result.fetchone() - image_data = ImageData_indi( + image_data = ImageResultData( image_id = row.id, user_id = row.user_id, image_loc = row.image_loc, @@ -335,3 +416,109 @@ async def get_image_result(conn: Connection, print(e) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") + +async def get_user_histories(conn: Connection, user_id: int): + """사용자의 전체 이미지 분석 히스토리를 최신순으로 조회한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + user_id (int): 조회할 사용자 PK. + + Returns: + list[ImageData]: 이미지 히스토리 스키마 리스트. 결과 없으면 빈 리스트. + + Raises: + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ + try: + # SQL에 맞춰 테이블명과 컬럼 변경 + query = (""" + SELECT id, image_loc, label, version_type, model_type, domain_type, created_at + FROM image_result + WHERE user_id = :user_id + ORDER BY created_at DESC; + """) + stmt = text(query) + result = await conn.execute(stmt, {"user_id": user_id}) + + user_histories = [ImageData( + image_id = row.id, + image_loc = row.image_loc, + label = row.label, + version_type = row.version_type, + model_type = row.model_type, + domain_type = row.domain_type, + created_at = row.created_at + ) for row in result] + + result.close() + return user_histories + + except SQLAlchemyError as e: + print(f"히스토리 조회 실패: {e}") + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") + + except Exception as e: + print(e) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") + +async def get_user_history(conn: Connection, image_id: int): + """image_id로 이미지 개별 상세 히스토리를 조회한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + image_id (int): 조회할 이미지 레코드 PK. + + Returns: + ImageDetailData | None: 상세 히스토리 스키마 객체. 레코드 없으면 None. + + Raises: + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ + try: + query = """ + SELECT id, image_loc, status, label, score, face_conf, face_ratio, face_brightness, version_type, model_type, domain_type, result_msg, created_at + FROM image_result + WHERE id = :image_id; + """ + stmt = text(query) + result = await conn.execute(stmt, {"image_id": image_id}) + + row = result.fetchone() + if row is None: + return None + + user_history = ImageDetailData( + image_id = row.id, + image_loc = row.image_loc, + status = row.status, + label = row.label, + score = row.score, + face_conf = row.face_conf, + face_ratio = row.face_ratio, + face_brightness = row.face_brightness, + version_type = row.version_type, + model_type = row.model_type, + domain_type = row.domain_type, + result_msg = row.result_msg, + created_at = row.created_at + ) + + result.close() + + return user_history + + except SQLAlchemyError as e: + print(f"히스토리 조회 실패: {e}") + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") + + except HTTPException: + raise + + except Exception as e: + print(e) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") + + \ No newline at end of file diff --git a/App/services/inference_svc.py b/App/services/inference_svc.py index 3ca92d6..d96838e 100644 --- a/App/services/inference_svc.py +++ b/App/services/inference_svc.py @@ -1,19 +1,16 @@ import asyncio -from inference.image_predictor_prt import ImagePredictor -from inference.video_predictor_prt import VideoPredictor -from inference.utils import PredictorError +from typing import Literal from fastapi import status, Depends from fastapi.exceptions import HTTPException from sqlalchemy import text, Connection from sqlalchemy.exc import SQLAlchemyError from services import image_svc, video_svc -from db.database import context_get_conn, celery_db_conn, engine +from db.database import celery_db_conn from celery_app import celery_app +from inference.image_predictor_prt import ImagePredictor +from inference.video_predictor_prt import VideoPredictor +from inference.utils import PredictorError -image_cache = {} -video_cache = {} - -# 사용자 모델 설정 변수명 MODEL_CONFIG = { 'v1': { 'fast': {'서양인': ("ms_eff_vit_b0", "ff++")}, @@ -31,29 +28,71 @@ } } -# 사용자 이미지 딥페이크 여부 판단 로직 -def predict_image(image_loc: str, version_type: str, model_type: str, domain_type: str): - - # image_loc: static 폴더 내 저장된 사용자 이미지 경로 - # version_type: v1, v2 - # model_type: fast, pro - # domain_type: 서양인, 동양인 - - model_name, dataset = MODEL_CONFIG[version_type][model_type][domain_type] +_image_cache: dict = {} +_video_cache: dict = {} + +def _get_or_create_predictor( + model_name: str, + dataset: str, + predictor_mode: Literal["image", "video"], +) -> ImagePredictor | VideoPredictor: + """캐시에서 Predictor를 조회하거나, 없으면 새로 생성해 캐싱 후 반환한다. + + 이미지/비디오 캐시를 분리 관리해 모드별 독립적인 인스턴스를 유지한다. + + Args: + model_name (str): 추론에 사용할 모델 아키텍처 이름. + dataset (str): 모델 학습에 사용된 데이터셋 이름. + predictor_mode (Literal["image", "video"]): Predictor 종류 선택. - # 캐시 확인 및 모델 초기화 + Returns: + ImagePredictor | VideoPredictor: 캐시된 또는 새로 생성된 Predictor 인스턴스. + """ cache_key = (model_name, dataset) - if cache_key not in image_cache: - image_cache[cache_key] = ImagePredictor( + + if predictor_mode == "image": + if cache_key not in _image_cache: + _image_cache[cache_key] = ImagePredictor( + margin_ratio=0.2, + conf_thres=0.5, + model_name=model_name, + dataset=dataset, + ) + return _image_cache[cache_key] + + if cache_key not in _video_cache: + _video_cache[cache_key] = VideoPredictor( margin_ratio=0.2, conf_thres=0.5, model_name=model_name, - dataset=dataset + dataset=dataset, ) - predictor = image_cache[cache_key] + return _video_cache[cache_key] + +def predict_image(image_loc: str, version_type: str, model_type: str, domain_type: str) -> dict: + """이미지 경로를 받아 딥페이크 여부를 추론하고 분석 결과를 반환한다. + + Args: + image_loc (str): 추론 대상 이미지의 서버 저장 경로 (DB 기준, '/'로 시작). + version_type (str): 모델 버전 타입 (MODEL_CONFIG 키값). + model_type (str): 추론 모드 ("fast" | "pro"). + domain_type (str): 탐지 도메인 ("서양인" | "동양인"). + + Returns: + dict: + - analysis (dict): prob, face_conf, face_ratio, face_brightness 포함. + 얼굴 미감지 또는 오류 발생 시 모든 값 -1. + - message (str): 처리 결과 메세지. + - status (str): "success" | "warning" | "failed" + """ + + model_name, dataset = MODEL_CONFIG[version_type][model_type][domain_type] + + predictor = _get_or_create_predictor(model_name, dataset, "image") + image_path = "." + image_loc try: - analysis = predictor.predict_img("." + image_loc) + analysis = predictor.predict_img(image_path) print(f"딥페이크 확률 값: {analysis['prob']}, 얼굴 신뢰도: {analysis['face_conf']}, 얼굴 비율: {analysis['face_ratio']}, 얼굴 밝기: {analysis['face_brightness']}") @@ -80,10 +119,25 @@ def predict_image(image_loc: str, version_type: str, model_type: str, domain_typ "status": "failed" } -# Celery 이미지 추론 Task @celery_app.task(name="process_image_task") def process_image_task(image_id: int, image_loc: str, version_type: str, model_type: str, - domain_type: str, user_id: int | None): + domain_type: str, user_id: int | None) -> None: + """딥페이크 이미지 추론을 처리하는 Celery 비동기 태스크. + + 추론 결과를 DB에 저장하며, SQLAlchemy 오류 발생 시 실패 상태로 업데이트한다. + Celery 워커(동기) 내에서 asyncio 이벤트 루프를 직접 구동해 비동기 로직을 실행한다. + + Args: + image_id (int): 이미지 레코드 PK. + image_loc (str): 추론 대상 이미지의 서버 저장 경로 (DB 기준, '/'로 시작). + version_type (str): 모델 버전 타입 (MODEL_CONFIG 키값). + model_type (str): 추론 모드 ("fast" | "pro"). + domain_type (str): 탐지 도메인 ("서양인" | "동양인"). + user_id (int | None): 로그인 사용자 ID. 비회원이면 None. + + Note: + 비회원(user_id=None) 요청은 추론 완료 60초 후 이미지 파일 및 DB 레코드가 자동 삭제된다. + """ async def run_inference(): try: # 이미지 추론, DeepFake 결과값 반환 @@ -127,29 +181,30 @@ async def run_inference(): loop.close() -# 사용자 비디오 딥페이크 여부 판단 로직 -def predict_video(video_loc: str, version_type: str, model_type: str, domain_type: str): - - # video_loc: static 폴더 내 저장된 사용자 비디오 경로 - # version_type: v1, v2 - # model_type: fast, pro - # domain_type: 서양인, 동양인 - +def predict_video(video_loc: str, version_type: str, model_type: str, domain_type: str) -> dict: + """비디오 경로를 받아 딥페이크 여부를 추론하고 분석 결과를 반환한다. + + Args: + video_loc (str): 추론 대상 비디오의 서버 저장 경로 (DB 기준, '/'로 시작). + version_type (str): 모델 버전 타입 (MODEL_CONFIG 키값). + model_type (str): 추론 모드 ("fast" | "pro"). + domain_type (str): 탐지 도메인 ("서양인" | "동양인"). + + Returns: + dict: + - analysis (dict): prob, face_conf, face_ratio, face_brightness, + frame_results, fps, total_frames, num_sampled, num_extracted, num_detected 포함. + 오류 발생 시 주요 수치 값 -1. + - message (str): 처리 결과 메세지. + - status (str): "success" | "warning" | "failed" + """ model_name, dataset = MODEL_CONFIG[version_type][model_type][domain_type] - # 캐시 확인 및 모델 초기화 - cache_key = (model_name, dataset) - if cache_key not in video_cache: - video_cache[cache_key] = VideoPredictor( - margin_ratio=0.2, - conf_thres=0.5, - model_name=model_name, - dataset=dataset - ) - predictor = video_cache[cache_key] + predictor = _get_or_create_predictor(model_name, dataset, "video") + video_path = "." + video_loc try: - analysis = predictor.predict_video("." + video_loc) + analysis = predictor.predict_video(video_path) print(f"딥페이크 확률 값: {analysis['prob']}, 얼굴 신뢰도: {analysis['face_conf']}, 얼굴 비율: {analysis['face_ratio']}, 얼굴 밝기: {analysis['face_brightness']}") @@ -178,10 +233,26 @@ def predict_video(video_loc: str, version_type: str, model_type: str, domain_typ "status": "failed" } -# Celery 비디오 추론 Task @celery_app.task(name="process_video_task") def process_video_task(video_id: int, video_loc: str, version_type: str, model_type: str, - domain_type: str, user_id: int | None): + domain_type: str, user_id: int | None) -> None: + """딥페이크 비디오 추론을 처리하는 Celery 비동기 태스크. + + 추론 결과를 DB에 저장하며, 성공 시 메타 데이터와 프레임별 결과를 추가로 저장한다. + 메타/프레임 저장 실패는 독립적으로 처리되어 메인 추론 결과에 영향을 주지 않는다. + Celery 워커(동기) 내에서 asyncio 이벤트 루프를 직접 구동해 비동기 로직을 실행한다. + + Args: + video_id (int): 비디오 레코드 PK. + video_loc (str): 추론 대상 비디오의 서버 저장 경로 (DB 기준, '/'로 시작). + version_type (str): 모델 버전 타입 (MODEL_CONFIG 키값). + model_type (str): 추론 모드 ("fast" | "pro"). + domain_type (str): 탐지 도메인 ("서양인" | "동양인"). + user_id (int | None): 로그인 사용자 ID. 비회원이면 None. + + Note: + 비회원(user_id=None) 요청은 추론 완료 60초 후 비디오 파일 및 DB 레코드가 자동 삭제된다. + """ async def run_inference(): try: # 비디오 추론, DeepFake 결과값 반환 @@ -207,7 +278,7 @@ async def run_inference(): if result["analysis"].get("frame_results"): try: async with celery_db_conn() as conn: - await video_svc.save_video_frame_results( + await video_svc.save_video_frame_result( conn, video_id, result["analysis"]["frame_results"] ) except Exception as e: @@ -232,7 +303,8 @@ async def run_inference(): finally: if not user_id: - video_svc.cleanup_anonymous_video.apply_async(args=[video_id, video_loc], countdown=60) + is_success = result.get("status") == "success" + video_svc.cleanup_anonymous_video.apply_async(args=[video_id, video_loc, is_success], countdown=60) diff --git a/App/services/session_svc.py b/App/services/session_svc.py index c9845e1..a144196 100644 --- a/App/services/session_svc.py +++ b/App/services/session_svc.py @@ -1,12 +1,36 @@ -from fastapi import APIRouter, Depends, status, Request +from fastapi import status, Request from fastapi.exceptions import HTTPException -def get_session_user_opt(request:Request): +def get_session_user_opt(request:Request) -> dict | None: + """세션에서 로그인 사용자 정보를 조회한다. 비로그인 상태면 None을 반환한다. + + 로그인 여부와 관계없이 접근 가능한 엔드포인트에 사용한다. + + Args: + request (Request): FastAPI 요청 객체. + + Returns: + dict | None: 로그인 상태면 {"id": int, "name": str, "email": str} 반환, + 비로그인 상태면 None. + """ if "session_user" in request.state.session: return request.state.session["session_user"] -def get_session_user_prt(request:Request): +def get_session_user_prt(request:Request) -> dict: + """세션에서 로그인 사용자 정보를 조회한다. 비로그인 상태면 401을 raise한다. + + 로그인이 필수인 엔드포인트의 Depends 의존성으로 사용한다. + + Args: + request (Request): FastAPI 요청 객체. + + Returns: + dict: {"id": int, "name": str, "email": str} + + Raises: + HTTPException 401: 세션에 사용자 정보가 없을 때. + """ if "session_user" not in request.state.session: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="해당 서비스는 로그인이 필요합니다.") diff --git a/App/services/video_svc.py b/App/services/video_svc.py index cdf5ac4..ae3323c 100644 --- a/App/services/video_svc.py +++ b/App/services/video_svc.py @@ -1,22 +1,34 @@ import os -import asyncio import time -import aiofiles as aio from dotenv import load_dotenv - +import asyncio +import aiofiles as aio from fastapi import UploadFile, status from fastapi.exceptions import HTTPException from sqlalchemy import text, Connection -from sqlalchemy.exc import SQLAlchemyError, DBAPIError +from sqlalchemy.exc import SQLAlchemyError from schemas.video_schema import ( - VideoData, VideoDetailData, VideoFrameData, VideoMetaData ) + VideoData, VideoDetailData, VideoFrameData, VideoMetaData) from db.database import celery_db_conn from celery_app import celery_app + load_dotenv() UPLOAD_DIR = os.getenv("UPLOAD_DIR") -# 사용자 업로드 동영상 서버 내 저장 (회원/비회원 공통) + async def upload_video(user_email: str | None, videofile: UploadFile) -> str: + """업로드된 비디오를 서버에 저장하고 DB 저장용 경로를 반환한다. 회원/비회원 공통. + + Args: + user_email (str | None): 로그인 사용자 이메일. 비회원이면 None. + videofile (UploadFile): FastAPI 업로드 파일 객체. + + Returns: + str: 저장된 비디오의 DB 기준 경로 ('/'로 시작, '\\' 정규화됨). + + Raises: + HTTPException 500: 디렉토리 생성, 파일 쓰기, 또는 예기치 못한 오류 발생 시. + """ try: # 1. 사용자별 하위 디렉토리 결정 sub_dir = user_email if user_email else "anonymous" @@ -52,8 +64,6 @@ async def upload_video(user_email: str | None, videofile: UploadFile) -> str: detail="비디오 파일을 저장하는 중 오류가 발생했습니다." ) - print(f"Upload Succeeded: {upload_video_loc}") - # 6. DB 저장용 경로 반환 return upload_video_loc[1:].replace("\\", "/") @@ -66,14 +76,23 @@ async def upload_video(user_email: str | None, videofile: UploadFile) -> str: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="비디오 업로드 과정에서 예상치 못한 오류가 발생했습니다.") -# 사용자 업로드 비디오 서버 내 삭제 -async def delete_video(video_loc: str): + +async def delete_video(video_loc: str) -> None: + """서버에서 비디오 물리 파일을 삭제한다. + + 파일이 존재하지 않으면 경고 로그만 출력하고 정상 종료한다. + + Args: + video_loc (str): 삭제할 비디오의 DB 기준 경로 ('/'로 시작). + + Raises: + HTTPException 500: 파일 삭제 중 오류 발생 시. + """ try: file_path = "." + video_loc if os.path.exists(file_path): os.remove(file_path) - print(f"Video file removed: {file_path}") else: print(f"Video file not found: {file_path}") @@ -84,8 +103,21 @@ async def delete_video(video_loc: str): detail="비디오 파일 삭제 중 오류가 발생했습니다." ) -# 사용자 전체 비디오 히스토리 조회 -async def get_user_histories(conn: Connection, user_id: int): + +async def get_user_histories(conn: Connection, user_id: int) -> list[VideoData]: + """사용자의 전체 비디오 분석 히스토리를 최신순으로 조회한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + user_id (int): 조회할 사용자 PK. + + Returns: + list[VideoData]: 비디오 히스토리 스키마 리스트. 결과 없으면 빈 리스트. + + Raises: + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ try: query = """ SELECT id, user_id, video_loc, status, label, @@ -95,8 +127,7 @@ async def get_user_histories(conn: Connection, user_id: int): ORDER BY created_at DESC; """ stmt = text(query) - bind_stmt = stmt.bindparams(user_id=user_id) - result = await conn.execute(bind_stmt) + result = await conn.execute(stmt, {"user_id": user_id}) video_histories = [VideoData( id = row.id, @@ -120,19 +151,44 @@ async def get_user_histories(conn: Connection, user_id: int): except Exception as e: print(e) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") - -# 사용자 개별 비디오 히스토리 조회 -async def get_user_history(conn: Connection, user_id: int, video_id: int): + +async def get_user_history(conn: Connection, user_id: int, video_id: int) -> VideoDetailData | None: + """video_id와 user_id로 비디오 개별 상세 히스토리를 조회한다. + + 본인 소유 데이터만 조회되도록 user_id를 함께 필터링한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + user_id (int): 요청 사용자 PK. + video_id (int): 조회할 비디오 레코드 PK. + + Returns: + VideoDetailData | None: 상세 히스토리 스키마 객체. 레코드 없으면 None. + + Raises: + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ try: - stmt = text(""" - SELECT id, user_id, video_loc, status, label, score, face_conf, face_ratio, - face_brightness, version_type, model_type, domain_type, result_msg, created_at - FROM video_result - WHERE id = :video_id AND user_id = :user_id - """) - result = await conn.execute(stmt, {"video_id": video_id, "user_id": user_id}) - + # user_id 있으면 본인 것만, None이면 video_id로만 조회 (비회원 상세 허용) + if user_id is not None: + stmt = text(""" + SELECT id, user_id, video_loc, status, label, score, face_conf, face_ratio, + face_brightness, version_type, model_type, domain_type, result_msg, created_at + FROM video_result + WHERE id = :video_id AND user_id = :user_id + """) + params = {"video_id": video_id, "user_id": user_id} + else: + stmt = text(""" + SELECT id, user_id, video_loc, status, label, score, face_conf, face_ratio, + face_brightness, version_type, model_type, domain_type, result_msg, created_at + FROM video_result + WHERE id = :video_id + """) + params = {"video_id": video_id} + result = await conn.execute(stmt, params) row = result.fetchone() if row is None: return None @@ -163,53 +219,26 @@ async def get_user_history(conn: Connection, user_id: int, video_id: int): except Exception as e: print(e) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") - -# 비회원 데이터 5분 후 자동 삭제 태스크 -@celery_app.task(name="cleanup_anonymous_video") -def cleanup_anonymous_video(video_id: int, video_loc: str): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - async def _delete(): - async with celery_db_conn() as conn: - await delete_video_db(conn, video_id) - await delete_video(video_loc) - print(f"[Cleanup] 비회원 비디오 삭제 완료 - video_id: {video_id}, video_loc: {video_loc}") - loop.run_until_complete(_delete()) - except Exception as e: - print(f"[Cleanup Error] 비회원 비디오 삭제 실패 - video_id: {video_id}, error: {e}") - finally: - loop.close() - -# 비디오 DB 레코드 및 물리 파일 완전 삭제 -async def delete_video_db(conn: Connection, video_id: int): - try: - delete_query = text("DELETE FROM video_result WHERE id = :video_id") - result = await conn.execute(delete_query.bindparams(video_id=video_id)) - if result.rowcount == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"해당 비디오 id {id}는(은) 존재하지 않아 삭제할 수 없습니다.") - - await conn.commit() - - except SQLAlchemyError as e: - print(e) - await conn.rollback() - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") +async def register_video_result(conn: Connection, user_id: int | None, video_loc: str, + version_type: str, model_type: str, domain_type: str) -> int: + """PENDING 상태의 빈 비디오 레코드를 DB에 생성하고 video_id를 반환한다. - except HTTPException: - raise - - except Exception as e: - print(e) - await conn.rollback() - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + user_id (int | None): 로그인 사용자 ID. 비회원이면 None. + video_loc (str): 업로드된 비디오의 DB 기준 경로. + version_type (str): 모델 버전 타입. + model_type (str): 추론 모드 ("fast" | "pro"). + domain_type (str): 탐지 도메인. + Returns: + int: 생성된 비디오 레코드의 PK (auto_increment). -# 빈 비디오 DB 생성 이후, video_id 반환(접수 완료) -async def register_video_result(conn: Connection, user_id: int | None, video_loc: str, - version_type: str, model_type: str, domain_type: str): + Raises: + HTTPException 400: DB INSERT 실패 시. 업로드된 물리 파일도 함께 롤백 삭제된다. + """ try: query = """ INSERT INTO video_result ( @@ -240,11 +269,22 @@ async def register_video_result(conn: Connection, user_id: int | None, video_loc await delete_video(video_loc) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="요청데이터가 제대로 전달되지 않았습니다") - -# 비디오 메타데이터 + 추론 결과값 DB에 저장 + + async def update_video_result(conn: Connection, video_id: int, analysis: dict, - result_msg: str, status: str): - + result_msg: str, status: str) -> None: + """추론 완료 후 비디오 레코드에 분석 결과를 업데이트한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 업데이트할 비디오 레코드 PK. + analysis (dict): 추론 결과값. prob, face_conf, face_ratio, face_brightness 포함. + result_msg (str): 처리 결과 메세지. + status (str): 추론 상태 ("success" | "warning" | "failed"). + + Raises: + SQLAlchemyError: DB 업데이트 실패 시 그대로 re-raise. + """ if status == 'success': label = "FAKE" if analysis["prob"] > 0.5 else "REAL" else: @@ -264,7 +304,7 @@ async def update_video_result(conn: Connection, video_id: int, analysis: dict, """ db_status = status.upper() - + stmt = text(query) await conn.execute(stmt, { "status": db_status, @@ -281,10 +321,23 @@ async def update_video_result(conn: Connection, video_id: int, analysis: dict, except SQLAlchemyError as e: await conn.rollback() raise e - -# 비디오 결과 값 가져오기 -async def get_video_result(conn: Connection, - video_id: int): + + +async def get_video_result(conn: Connection, video_id: int) -> VideoDetailData: + """video_id로 비디오 분석 결과를 조회해 반환한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 조회할 비디오 레코드 PK. + + Returns: + VideoDetailData: 비디오 분석 결과 스키마 객체. + + Raises: + HTTPException 404: 해당 video_id 레코드가 없을 때. + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ try: query = text("SELECT * FROM video_result WHERE id = :video_id") result = await conn.execute(query, {"video_id": video_id}) @@ -324,8 +377,19 @@ async def get_video_result(conn: Connection, raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") -# 비디오 메타 데이터 정보 저장하기 -async def save_video_meta_result(conn: Connection, video_id: int, analysis: dict): + +async def save_video_meta_result(conn: Connection, video_id: int, analysis: dict) -> None: + """비디오 1개의 프레임 요약 통계를 video_meta_result 테이블에 저장한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 비디오 레코드 PK. + analysis (dict): 추론 결과 딕셔너리. fps, total_frames, num_sampled, + num_extracted, num_detected 키를 포함해야 한다. + + Raises: + SQLAlchemyError: DB INSERT 실패 시 그대로 re-raise. + """ try: query = """ INSERT INTO video_meta_result (video_id, fps, total_frames, num_sampled, num_extracted, num_detected) @@ -344,10 +408,21 @@ async def save_video_meta_result(conn: Connection, video_id: int, analysis: dict except SQLAlchemyError as e: await conn.rollback() raise e - -# 비디오 상세 결과 값 저장하기 -# 비디오 프레임 별 딥페이크 점수, 얼굴 신뢰도, 얼굴 비율, 얼굴 조도 반환 -async def save_video_frame_results(conn: Connection, video_id: int, frame_results: list): + + +async def save_video_frame_result(conn: Connection, video_id: int, frame_results: list) -> None: + """비디오에 속한 모든 프레임의 분석 결과를 video_frame_result 테이블에 일괄 저장한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 비디오 레코드 PK. + frame_results (list): 프레임별 분석 결과 딕셔너리 리스트. + 각 항목은 frame_index, frame_time, score, face_conf, + face_ratio, face_brightness 키를 포함해야 한다. + + Raises: + SQLAlchemyError: DB INSERT 실패 시 그대로 re-raise. + """ try: query = """ INSERT INTO video_frame_result @@ -373,9 +448,99 @@ async def save_video_frame_results(conn: Connection, video_id: int, frame_result except SQLAlchemyError as e: await conn.rollback() raise e + + +async def delete_video_meta_result(conn: Connection, video_id: int) -> None: + """video_meta_result 테이블에서 해당 비디오의 메타 데이터를 삭제한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 삭제할 비디오 레코드 PK. + + Raises: + HTTPException 404: 해당 video_id 레코드가 없을 때. + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ + try: + delete_query = text(""" + DELETE FROM video_meta_result + WHERE video_id = :video_id + """) + result = await conn.execute(delete_query, {"video_id": video_id}) + if result.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"해당 비디오 id {video_id}는(은) 존재하지 않아 삭제할 수 없습니다.") + + await conn.commit() + + except SQLAlchemyError as e: + print(e) + await conn.rollback() + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") + + except HTTPException: + raise + + except Exception as e: + print(e) + await conn.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") + + +async def delete_video_frame_result(conn: Connection, video_id: int) -> None: + """video_frame_result 테이블에서 해당 비디오의 프레임 결과를 삭제한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 삭제할 비디오 레코드 PK. + + Raises: + HTTPException 404: 해당 video_id 레코드가 없을 때. + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ + try: + delete_query = text(""" + DELETE FROM video_frame_result + WHERE video_id = :video_id + """) + result = await conn.execute(delete_query, {"video_id": video_id}) + if result.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"해당 비디오 id {video_id}는(은) 존재하지 않아 삭제할 수 없습니다.") -# 비디오 메타 데이터 정보 가져오기 -async def get_video_meta_result(conn: Connection, video_id: int): + await conn.commit() + + except SQLAlchemyError as e: + print(e) + await conn.rollback() + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") + + except HTTPException: + raise + + except Exception as e: + print(e) + await conn.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") + + +async def get_video_meta_result(conn: Connection, video_id: int) -> VideoMetaData: + """video_id로 비디오 메타 데이터를 조회한다. + + fps, total_frames, num_sampled, num_extracted, num_detected를 반환한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 조회할 비디오 레코드 PK. + + Returns: + VideoMetaData: 비디오 메타 데이터 스키마 객체. + + Raises: + HTTPException 404: 해당 video_id 메타 레코드가 없을 때. + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ try: query = text(""" SELECT fps, total_frames, num_sampled, num_extracted, num_detected @@ -405,9 +570,24 @@ async def get_video_meta_result(conn: Connection, video_id: int): except Exception as e: print(e) raise HTTPException(status_code=500, detail="알수없는 이유로 문제가 발생하였습니다.") - -# 비디오 상세 결과 값 가져오기 -async def get_video_frame_results(conn: Connection, video_id: int): + + +async def get_video_frame_result(conn: Connection, video_id: int) -> list[VideoFrameData]: + """video_id에 속한 모든 프레임 분석 결과를 frame_index 오름차순으로 조회한다. + + 프레임별 점수 그래프, 의심 구간 표시 등 시각화에 활용된다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 조회할 비디오 레코드 PK. + + Returns: + list[VideoFrameData]: 프레임별 분석 결과 스키마 리스트. + + Raises: + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ try: query = text(""" SELECT frame_index, frame_time, score, face_conf, face_ratio, face_brightness @@ -429,11 +609,135 @@ async def get_video_frame_results(conn: Connection, video_id: int): for r in result ] + except SQLAlchemyError as e: + print(f"[Frame Query Error] {e}") + raise HTTPException(status_code=503, detail="데이터베이스 조회 중 문제가 발생했습니다.") + except Exception as e: + print(e) + raise HTTPException(status_code=500, detail="알수없는 이유로 문제가 발생하였습니다.") + + +async def get_video_frame_by_index(conn: Connection, video_id: int, frame_index: int) -> float: + """video_id와 frame_index로 특정 프레임의 frame_time을 조회한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 비디오 레코드 PK. + frame_index (int): 조회할 프레임 인덱스. + + Returns: + float: 해당 프레임의 타임스탬프 (초 단위). + + Raises: + HTTPException 404: 해당 frame_index가 없을 때. + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ + try: + query = text(""" + SELECT frame_time + FROM video_frame_result + WHERE video_id = :video_id AND frame_index = :frame_index + """) + + result = await conn.execute(query, {"video_id": video_id, "frame_index": frame_index}) + if result.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"해당 ID: {video_id} Video 내 Frame Index {frame_index}에 해당하는 프레임을 찾을 수 없습니다") + + row = result.fetchone() + return row.frame_time except SQLAlchemyError as e: print(f"[Frame Query Error] {e}") raise HTTPException(status_code=503, detail="데이터베이스 조회 중 문제가 발생했습니다.") + except HTTPException: + raise except Exception as e: print(e) - raise HTTPException(status_code=500, detail="알수없는 이유로 문제가 발생하였습니다.") \ No newline at end of file + raise HTTPException(status_code=500, detail="알수없는 이유로 문제가 발생하였습니다.") + + +@celery_app.task(name="cleanup_anonymous_video") +def cleanup_anonymous_video(video_id: int, video_loc: str, is_success: bool) -> None: + """비회원 비디오 DB 레코드 및 물리 파일을 삭제하는 Celery 태스크. + + 추론 성공 시 meta/frame 결과도 함께 삭제한다. + 각 삭제 단계는 독립적으로 처리되어 한쪽 실패가 다른쪽에 영향을 주지 않는다. + + Args: + video_id (int): 삭제할 비디오 레코드 PK. + video_loc (str): 삭제할 비디오의 DB 기준 경로. + is_success (bool): 추론 성공 여부. True면 meta/frame 결과도 함께 삭제. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + async def _delete(): + success = True + async with celery_db_conn() as conn: + if is_success: + try: + await delete_video_meta_result(conn, video_id) + except Exception as e: + print(f"[Cleanup] video meta 삭제 실패 - video_id: {video_id}, error: {e}") + success=False + + try: + await delete_video_frame_result(conn, video_id) + except Exception as e: + print(f"[Cleanup] video frame 삭제 실패 - video_id: {video_id}, error: {e}") + success=False + try: + await delete_video_db(conn, video_id) + except Exception as e: + print(f"[Cleanup] video_result 삭제 실패 - video_id: {video_id}, error:{e}") + success=False + try: + await delete_video(video_loc) + except Exception as e: + print(f"[Cleanup] 비디오 파일 삭제 실패 - video_loc: {video_loc}, error: {e}") + success=False + + if success: + print(f"[Cleanup] 비회원 비디오 삭제 완료 - video_id: {video_id}, video_loc: {video_loc}") + + loop.run_until_complete(_delete()) + finally: + loop.close() + + +async def delete_video_db(conn: Connection, video_id: int) -> None: + """DB에서 비디오 레코드를 삭제한다. + + Args: + conn (Connection): SQLAlchemy 비동기 DB 커넥션. + video_id (int): 삭제할 비디오 레코드 PK. + + Raises: + HTTPException 404: 해당 video_id 레코드가 없을 때. + HTTPException 503: DB 쿼리 중 SQLAlchemy 오류 발생 시. + HTTPException 500: 예기치 못한 오류 발생 시. + """ + try: + delete_query = text("DELETE FROM video_result WHERE id = :video_id") + result = await conn.execute(delete_query, {"video_id": video_id}) + + if result.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"해당 비디오 id {video_id}는(은) 존재하지 않아 삭제할 수 없습니다.") + + await conn.commit() + + except SQLAlchemyError as e: + print(e) + await conn.rollback() + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.") + + except HTTPException: + raise + + except Exception as e: + print(e) + await conn.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="알수없는 이유로 문제가 발생하였습니다.") \ No newline at end of file diff --git a/App/sql/initial_video_frame_result.sql b/App/sql/initial_video_frame_result.sql index c946b5d..d280613 100644 --- a/App/sql/initial_video_frame_result.sql +++ b/App/sql/initial_video_frame_result.sql @@ -1,14 +1,14 @@ drop table if exists video_frame_result; create table deepfake_db.video_frame_result ( - id integer auto_increment primary key, - video_id integer not null, - frame_index integer not null, + id integer auto_increment primary key, -- 행 자체의 고유 ID (대리키) + video_id integer not null, + frame_index integer not null, -- 영상 내 프레임 순번. frame_time float not null, -- 영상 내 타임스탬프(초) - score float not null, - face_conf float not null, - face_ratio float not null, - face_brightness float not null, + score float not null,-- 해당 프레임의 딥페이크 의심 점수 (0.0~1.0) + face_conf float not null,-- 얼굴 검출 신뢰도. 낮으면 score 신뢰도도 낮음. + face_ratio float not null,-- 프레임 대비 얼굴 면적 비율 (0.0~1.0) + face_brightness float not null,-- 얼굴 영역 평균 밝기 (조도 품질 지표) index video_id_idx(video_id) ); \ No newline at end of file diff --git a/App/sql/initial_video_meta_result.sql b/App/sql/initial_video_meta_result.sql index 80e95da..289f4d6 100644 --- a/App/sql/initial_video_meta_result.sql +++ b/App/sql/initial_video_meta_result.sql @@ -1,13 +1,13 @@ drop table if exists video_meta_result; create table deepfake_db.video_meta_result ( - id integer auto_increment primary key, + id integer auto_increment primary key,-- 행 자체의 고유 ID (대리키). video_id integer not null unique, - fps float not null, - total_frames integer not null, - num_sampled integer not null, - num_extracted integer not null, - num_detected integer not null, + fps float not null, -- 초당 프레임수 + total_frames integer not null,-- 영상 전체 프레임 수 + num_sampled integer not null,-- [1단계] 분석을 위해 샘플링한 프레임 수 + num_extracted integer not null,-- [2단계] 샘플 중 얼굴 추출에 성공한 프레임 수 + num_detected integer not null,-- [3단계] 추출된 얼굴 중 딥페이크 score 산출에 성공한 프레임 수 - index video_id_idx(video_id) + index video_id_idx(video_id)-- video_id로 메타 조회 시 사용 ); \ No newline at end of file diff --git a/App/sql/initial_video_result.sql b/App/sql/initial_video_result.sql index cc683a9..9daa890 100644 --- a/App/sql/initial_video_result.sql +++ b/App/sql/initial_video_result.sql @@ -1,20 +1,30 @@ drop table if exists video_result; +-- 딥페이크 분석 결과를 저장하는 테이블 생성 create table deepfake_db.video_result ( - id integer auto_increment primary key, - user_id integer null, -- null 허용 - video_loc varchar(300) not null unique, - status varchar(20) not null default "PENDING", - label varchar(10) not null, - score float not null, + id integer auto_increment primary key, -- 시스템에서 부여하는 데이터 고유 식별 번호 + user_id integer null, -- 영상을 업로드한 사용자의 ID (비회원 허용을 위해 null 가능) + video_loc varchar(300) not null unique, -- 영상 파일이 저장된 경로/URL (중복 등록 방지) + + -- 상태 및 결과 정보 + status varchar(20) not null default "PENDING", -- 분석 진행 상태 (대기중, 분석중, 완료, 실패 등) + label varchar(10) not null, -- 최종 판정 결과 (예: 'Fake', 'Real') + score float not null, + + -- 분석 품질 및 메타데이터 face_conf float not null, face_ratio float not null, face_brightness float not null, + + -- 분석 환경 정보 version_type varchar(10) not null, model_type varchar(10) not null, domain_type varchar(20) not null, - result_msg varchar(200) not null, - created_at timestamp default current_timestamp, - index user_id_idx(user_id) + -- 기타 기록 + result_msg varchar(200) not null, -- 분석 결과에 대한 상세 메시지나 오류 내용 + created_at timestamp default current_timestamp, -- 데이터가 생성된(분석 요청된) 시간 + + -- 성능 최적화 + index user_id_idx(user_id) -- 특정 사용자의 분석 이력을 빠르게 조회하기 위한 인덱스 ); \ No newline at end of file diff --git a/App/static/uploads/yesol@gmail.com/western_fake_1777863367.mp4 b/App/static/uploads/yesol@gmail.com/western_fake_1777863367.mp4 deleted file mode 100644 index 0c7eb48..0000000 Binary files a/App/static/uploads/yesol@gmail.com/western_fake_1777863367.mp4 and /dev/null differ diff --git a/App/static/uploads/yesol@gmail.com/western_fake_1777863440.mp4 b/App/static/uploads/yesol@gmail.com/western_fake_1777863440.mp4 deleted file mode 100644 index 0c7eb48..0000000 Binary files a/App/static/uploads/yesol@gmail.com/western_fake_1777863440.mp4 and /dev/null differ diff --git a/App/static/uploads/yesol@gmail.com/western_fake_1778167444.mp4 b/App/static/uploads/yesol@gmail.com/western_fake_1778167444.mp4 deleted file mode 100644 index 0c7eb48..0000000 Binary files a/App/static/uploads/yesol@gmail.com/western_fake_1778167444.mp4 and /dev/null differ diff --git a/App/static/uploads/yesol@gmail.com/western_real_1_1778167744.JPG b/App/static/uploads/yesol@gmail.com/western_real_1_1778167744.JPG deleted file mode 100644 index f5be7e3..0000000 Binary files a/App/static/uploads/yesol@gmail.com/western_real_1_1778167744.JPG and /dev/null differ diff --git a/App/utils/common.py b/App/utils/common.py index c49d71c..a72d30c 100644 --- a/App/utils/common.py +++ b/App/utils/common.py @@ -2,12 +2,20 @@ from db.database import engine from contextlib import asynccontextmanager +# Uvicorn이 앱 시작/종료 시 asynccontextmanager를 호출함 @asynccontextmanager async def lifespan(app: FastAPI): - # FastAPI 인스턴스 기동시 필요한 작업 수행 + + # ── Startup ────────────────────────────────────────── + # Event Loop 위에서 실행, DB 연결 풀 초기화 print("Starting up...") + + # yield 이후 app이 실제 요청을 처리하기 시작함 yield - # FastAPI 인스턴스 종료시 필요한 작업 수행 + # ── Shutdown ───────────────────────────────────────── + # 새 요청은 차단되고, 진행 중인 요청 완료 후 여기로 진입 print("Shutting down...") + + # SQLAlchemy 비동기 엔진의 커넥션 풀 전체 반환 및 종료 await engine.dispose() \ No newline at end of file diff --git a/App/utils/middleware.py b/App/utils/middleware.py index d8a9589..12b2bc6 100644 --- a/App/utils/middleware.py +++ b/App/utils/middleware.py @@ -46,7 +46,9 @@ async def dispatch(self, request: Request, call_next): response = await call_next(request) if request.state.session: - is_https = request.url.scheme == 'https' + is_https = ( + request.url.scheme == 'https' + or request.headers.get('x-forwarded-proto') == 'https') response.set_cookie(self.session_cookie, session_id, max_age=self.max_age, httponly=True, secure=is_https, samesite='None' if is_https else 'Lax') diff --git a/App/utils/responses.py b/App/utils/responses.py new file mode 100644 index 0000000..4647454 --- /dev/null +++ b/App/utils/responses.py @@ -0,0 +1,4 @@ +from fastapi.responses import StreamingResponse + +class PNGStreamingResponse(StreamingResponse): + media_type = "image/png" \ No newline at end of file diff --git a/Images/celeb_df_v2_gcvit.png b/Images/celeb_df_v2_gcvit.png deleted file mode 100644 index 3704f61..0000000 Binary files a/Images/celeb_df_v2_gcvit.png and /dev/null differ diff --git a/Images/celeb_df_v2_vit.png b/Images/celeb_df_v2_vit.png deleted file mode 100644 index ebba05f..0000000 Binary files a/Images/celeb_df_v2_vit.png and /dev/null differ diff --git a/Images/deepfake.JPG b/Images/deepfake.JPG deleted file mode 100644 index 4506889..0000000 Binary files a/Images/deepfake.JPG and /dev/null differ diff --git a/Images/deepfake/video/western_fake.mp4 b/Images/deepfake/video/western_fake.mp4 deleted file mode 100644 index 0c7eb48..0000000 Binary files a/Images/deepfake/video/western_fake.mp4 and /dev/null differ diff --git a/Images/deepfake/video/western_real.mp4 b/Images/deepfake/video/western_real.mp4 deleted file mode 100644 index 2828600..0000000 Binary files a/Images/deepfake/video/western_real.mp4 and /dev/null differ diff --git a/Images/ff_compare.png b/Images/ff_compare.png deleted file mode 100644 index f75129b..0000000 Binary files a/Images/ff_compare.png and /dev/null differ diff --git a/Images/ff_gcvit.png b/Images/ff_gcvit.png deleted file mode 100644 index 536e068..0000000 Binary files a/Images/ff_gcvit.png and /dev/null differ diff --git a/Images/ff_vit.png b/Images/ff_vit.png deleted file mode 100644 index ad28527..0000000 Binary files a/Images/ff_vit.png and /dev/null differ diff --git a/Images/kodf_gcvit.png b/Images/kodf_gcvit.png deleted file mode 100644 index f90a973..0000000 Binary files a/Images/kodf_gcvit.png and /dev/null differ diff --git a/Images/model_architecture.JPG b/Images/model_architecture.JPG deleted file mode 100644 index 128e285..0000000 Binary files a/Images/model_architecture.JPG and /dev/null differ diff --git a/Images/ms_eff_gcvit.JPG b/Images/ms_eff_gcvit.JPG deleted file mode 100644 index b1ffced..0000000 Binary files a/Images/ms_eff_gcvit.JPG and /dev/null differ diff --git a/Images/multi_scale.JPG b/Images/multi_scale.JPG deleted file mode 100644 index 01d2f9e..0000000 Binary files a/Images/multi_scale.JPG and /dev/null differ diff --git a/README.md b/README.md index e481c00..755b854 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # Deepfakes Detection -

- DeepGuard Banner -

-

License Stars + Downloads + Last Commit Status Release + Repo Size +

+ +

+ DeepGuard Banner

@@ -27,6 +30,7 @@

🇰🇷 한국어 버전 | + 🇯🇵 日本語版 | 📈 Model Evaluation | 🔮 Try Demo

@@ -43,6 +47,7 @@ - [📈 Model Evaluation](#-model-evaluation) - Benchmarking results - [💻 Model Usage](#-model-usage) - How to integrate DeepGuard models into your own Python code or via timm - [🔮 Predict Image & Video](#-predict-image--video) - Simple Inference examples for detecting deepfakes in image and video +- [🎨 DeepFake AI Explainability](#-deepfake-ai-explainability) - Visualizing model focus using Grad-CAM and attention maps - [📬 Authors](#-authors) - [📝 Reference](#-reference) - [⚖️ License](#-license) @@ -138,7 +143,7 @@ Multi Scale Efficient Global Context Vision Transformer is an optimized multi-sc

- +

We utilizes two distinct types of self-attention to capture both long-range and short-range information across feature maps. @@ -149,7 +154,7 @@ We utilizes two distinct types of self-attention to capture both long-range and

- +

## 🧬 Model Zoo @@ -250,6 +255,21 @@ model = timm.create_model("ms_eff_gcvit_b0", pretrained=True, dataset="ff++") model = timm.create_model("ms_eff_gcvit_b5", pretrained=True, dataset="kodf") ``` +**Option C: Hugging Face Hub** + +```python +import torch +from huggingface_hub import hf_hub_download +from deepguard import ms_eff_gcvit_b0 # or ms_eff_gcvit_b5 + +REPO_ID = "KoreaPeter/ms-eff-gcvit-deepfake" + +ckpt = hf_hub_download(REPO_ID, "ms_eff_gcvit_b0_kodf.bin") # celeb_df_v2 | ff++ | kodf +model = ms_eff_gcvit_b0(pretrained=False) +model.load_state_dict(torch.load(ckpt, map_location="cpu")) +model.eval() +``` + ## 🔮 Predict Image & Video #### Predict DeepFake Image @@ -303,6 +323,151 @@ print(f"Deepfake Probability: {result:.4f}") ``` +## 🎨 DeepFake AI Explainability + +Deepfake detection is only as trustworthy as its explanations. DeepGuard integrates a production-ready XAI Toolkit that visualizes where and why the model flags a face as manipulated — turning a black-box score into actionable forensic evidence. + +⭐ Validated on hybrid CNN-ViT architectures, specifically `MS-EffViT` and `MS-EffGCViT`. +⭐ Dual-Branch Analysis: Dual-branch design mirrors the model's own multi-scale reasoning + +### 🧠 How Dual-Branch XAI Works + +

+ +

+ + +| Branch | Feature Map | Focus | Best For | +| ------ | ----------- | ----- | -------- | +| ![](https://img.shields.io/badge/Low_level-blue?style=flat-square) | High Resolution | Local Forgery artifacts | Skin texture, boundary blending, compression traces | +| ![](https://img.shields.io/badge/High_level-red?style=flat-square) | Low Resolution | Global Semantic Structure | Lighting inconsistency, facial geometry, Shadow artifacts | + +### 📐 XAI Methods + +Each method is assigned to the branch where it performs best empirically. + +| Branch | Method | 🎯 Core Idea | +| :--- | :--- | :--- | +| **`low level`** | **HiResCAM** | Like GradCAM but element-wise multiply the activations with the gradients; provably guaranteed faithfulness for certain models | +| **`low level`** | **GradCAMElementWise** | Like GradCAM but element-wise multiply the activations with the gradients then apply a ReLU operation before summing | +| **`low level`** | **LayerCAM** | Spatially weight the activations by positive gradients. Works better especially in lower layers | +| --- | --- | --- | --- | +| **`high level`** | **EigenGradCAM** | Like EigenCAM but with class discrimination: First principle component of Activations*Grad. Looks like GradCAM, but cleaner | +| **`high level`** | **GradCAM++** | Like GradCAM but uses second order gradients | +| **`high level`** | **XGradCAM** | Like GradCAM but scale the gradients by the normalized activations | + +- **`aug_smooth`** applies TTA (horizontal flips) before averaging CAMs → smoother, more object-aligned maps +- **`eigen_smooth`** applies PCA noise reduction → retains dominant forgery pattern only + +### 💡 DeepFake XAI Usage + +**Low-Level Branch — Local Artifact Detection** + +```python +from explainability import HiResCAMExplainer, GradCAMElementWiseExplainer, LayerCAMExplainer + +explainer = HiResCAMExplainer( + model_name = "ms_eff_gcvit_b0", # or ms_eff_vit_b0, ms_eff_gcvit_b5, ms_eff_vit_b5 + dataset = "celeb_df_v2", # or ff++, kodf + branch_level = "low", +) +``` + +**High-Level Branch — Global Semantic Detection** +```python +from explainability import EigenGradCAMExplainer, GradCAMPlusPlusExplainer, XGradCAMExplainer + +explainer = EigenGradCAMExplainer( + model_name = "ms_eff_gcvit_b0", + dataset = "celeb_df_v2", + branch_level = "high", +) +``` + +### 🎨 Visualization Modes + +

+ +

+ +**1. Heatmap — Continuous activation distribution** + +```python +result = explainer.display_heatmap_on_image( + img_path = "path/to/image.jpg", + category = 1, # 0: Real, 1: Fake + threshold = 0.5, # binarization cutoff (0.5~1.0), or "auto" for Otsu + image_weight = 0.5, # 0.0: heatmap only ← → 1.0: original only + aug_smooth = False, # TTA smoothing (not supported on 'pro' models) + eigen_smooth = False, # PCA noise reduction +) +``` + +**2. Bounding Box — Discrete forgery region localization** + +```python +result = explainer.display_bbox_on_image( + img_path = "path/to/image.jpg", + category = 1, + threshold = 0.5, + thickness = 1, + aug_smooth = False, + eigen_smooth = False, +) +``` + +**3. Heatmap + BBox — Full overlay (recommended for reporting)** +```python +result = explainer.display_heatmap_bbox_on_image( + img_path = "path/to/image.jpg", + category = 1, + threshold = 0.5, + image_weight = 0.5, + aug_smooth = False, + eigen_smooth = False, +) +``` + +### 📊 Visual Results + +

+ + + + + + +
+

+ +#### MS-EFF-VIT — Low-Level Branch + +| Model | Branch-Level | Image | HiresCam | GradCamElementwise | LayerCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-vit-b0** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | +| **🔥 ms-eff-vit-b5** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | + +#### MS-Eff-ViT — High-Level Branch + +| Model | Branch-Level | Image | EigenGradCam | GradCamPlusPlus | XGradCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-vit-b0** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | +| **🔥 ms-eff-vit-b5** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | + +#### MS-EFF-GCVIT — Low-Level Branch + +| Model | Branch-Level | Image | HiresCam | GradCamElementwise | LayerCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-gcvit-b0** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | +| **🔥 ms-eff-gcvit-b5** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | + +#### MS-Eff-GCViT — High-Level Branch + +| Model | Branch-Level | Image | EigenGradCam | GradCamPlusPlus | XGradCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-gcvit-b0** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | +| **🔥 ms-eff-gcvit-b5** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | + ## 📬 Authors @@ -323,6 +488,7 @@ _**This project was developed as a Senior Graduation Project by the Department o 6. [`deepfake-detection-project-v4`](https://github.com/ameencaslam/deepfake-detection-project-v4) - _Multiple Deep Learning Models by Ameen Caslam_ 7. [`Awesome-Deepfake-Detection`](https://github.com/Daisy-Zhang/Awesome-Deepfakes-Detection ) - _A curated list of tools, papers and code by Daisy Zhang_ +8. [`Pytorch-Grad-Cam`](https://github.com/jacobgil/pytorch-grad-cam) - _Advanced Visual Explanations for PyTorch Models_ ## ⚖️ License diff --git a/README_JP.md b/README_JP.md new file mode 100644 index 0000000..199f443 --- /dev/null +++ b/README_JP.md @@ -0,0 +1,484 @@ +# Deepfakes Detection (ディープフェイク検出) + +

+ License + Stars + Downloads + Last Commit + Status + Release + Repo Size +

+ +

+ DeepGuard Banner +

+ +

+ Task + FF++ + Celeb-DF + KODF +

+ +

+ Models + Python + PyTorch + W&B +

+ +

+ 🇺🇸 English Version | + 🇰🇷 한국어 버전 | + 📈 モデル評価 | + 🔮 デモ実行 +

+ +## 📌 目次 + +- [💡 インストールと要件](#-インストールと要件) +- [🛠 セットアップ](#-セットアップ) +- [📚 ディープフェイク動画ベンチマークデータセット](#-ディープフェイク動画ベンチマークデータセット) — 学習に使用した Celeb-DF-v2, FF++, KoDF データセットの概要。 +- [⚙️ データ準備](#データ準備) — YOLOv8 を用いた効率的な顔検出およびランドマーク抽出パイプライン。 +- [🏗 モデルアーキテクチャ](#-モデルアーキテクチャ) — ハイブリッド CNN-ViT (MS-EffViT & MS-EffGCViT) 設計の詳細。 +- [🧬 Model Zoo](#-model-zoo) — モデルバリアントごとのパラメータ数および演算量(FLOPs)の比較。 +- [🚀 学習](#-学習) - Google Colab および W&B を活用した段階的な学習スクリプト。 +- [📈 モデル評価](#-モデル評価) - ベンチマーク結果。 +- [💻 モデルの使い方](#-モデルの使い方) - Python コードおよび timm を通じた DeepGuard モデルの統合方法。 +- [🔮 画像と動画の予測](#-画像と動画の予測) - ディープフェイク検出のためのシンプルな推論例。 +- [🎨 ディープフェイクAI説明可能性(XAI)](#-ディープフェイクai説明可能性xai) - Grad-CAM およびアテンションマップによるモデル判断根拠の可視化。 +- [📬 制作者](#-制作者) +- [📝 参考文献](#-参考文献) +- [⚖️ ライセンス](#-ライセンス) + +--- + +## 💡 インストールと要件 + +必要なライブラリのインストール: + +```bash +pip install -r requirements.txt +``` + +## 🛠 セットアップ + +リポジトリをクローンし、該当ディレクトリへ移動します: +```bash +git clone https://github.com/HanMoonSub/DeepGuard.git +cd DeepGuard +``` + +## 📚 ディープフェイク動画ベンチマークデータセット + +モデルの汎化性能と頑健性を評価するため、広く認知された3つの大規模ベンチマークデータセットを使用します。各データセットはそれぞれ異なる改ざん手法と難易度の高い課題を含んでいます。 + +| データセット | 実写動画 | 偽造動画 | 年度 | 参加人数 | 説明 (論文タイトル) | 詳細 | +| :--- | :---: | :---: | :---: | :---: | :--- | :---: | +| **Celeb-DF-v2** | 890 | 5,639 | 2019 | 59 | *A Large-scale Challenging Dataset for DeepFake Forensics* | [🔗 Readme](preprocess/celeb_df_v2/README.md) | +| **FaceForensics++** | 1,000 | 6,000 | 2019 | 1,000 | *Learning to Detect Manipulated Facial Images* | [🔗 Readme](preprocess/ff++/README.md) | +| **KoDF** | 62,166 | 175,776 | 2020 | 400 | *Large-Scale Korean Deepfake Detection Dataset* | [🔗 Readme](preprocess/kodf/README.md) | + +
+ +## ⚙️ データ準備 + +前処理パイプラインは、動画から顔の特徴を効率的に抽出し、高精度なディープフェイク検出に備えるよう設計されています。 + +### オリジナル顔の検出 (Detect Original Face) +前処理の効率を最大化するため、**顔検出はオリジナル(Real)動画に対してのみ実行されます。** ベンチマークデータセットの改ざん動画はオリジナル動画と同じ空間座標を共有するため、抽出したバウンディングボックスを偽造動画にもそのまま再利用します。 + +🚀 **効率化の最適化** +- **軽量モデル**: 精度を維持しつつ高速な推論を実現する `yolov8n-face` を使用します。 +- **ターゲット処理**: オリジナル動画のみで顔を検出することで、全体の検出作業量を約80%削減しました。 +- **動的リサイズ**: 多様な解像度で一定の推論速度を維持するため、フレームサイズに応じて自動的にサイズを調整します: + +| フレームサイズ(長辺基準) | スケール係数 | 動作 | +| ------------------------ | ------------ | ------ | +| < 300px | 2.0 | ![](https://img.shields.io/badge/Upscale-green?style=flat-square) | +| 300px - 700px | 1.0 | ![](https://img.shields.io/badge/No_Change-gray?style=flat-square) | +| 700px - 1500px | 0.5 | ![](https://img.shields.io/badge/DownScale-skyblue?style=flat-square) | +| > 1500px | 0.33 | ![](https://img.shields.io/badge/DownScale-skyblue?style=flat-square) | + +### 顔クロップおよびランドマーク抽出 +このモジュールは、前段で生成したバウンディングボックスを用いて、オリジナル動画と偽造動画の両方から顔領域をクロップします。さらに、Landmark-based Cutout のような高度なaugmentation手法のためにランドマーク検出を行います。 + +🛠 **主な機能** +- **ジッターを含む動的マージン**: 顔周辺に設定可能なマージンを追加します。`margin_jitter` パラメータはクロップサイズにランダムな変化を与え、モデルが多様な顔スケールに対して頑健になるよう支援します。 +- **ランドマークのローカライズ**: 5つの主要な顔ランドマーク(目、鼻、口角)を検出し、`.npy` ファイルとして保存します。 + +```Plaintext +DATA_ROOT/ +├── crops/ +│ └── {video_id}/ +│ ├── 12.png +│ └── ... +├── landmarks/ +│ └── {video_id}/ +│ ├── 12.npy +│ └── ... +└── train_frame_metadata.csv +``` + +### データセット別パイプライン + +各データセットの具体的な前処理の詳細は、以下のリンクをクリックしてご確認ください: + +* [**Celeb-DF V2 前処理**](/preprocess/celeb_df_v2/README.md) +* [**FaceForensics++ 前処理**](/preprocess/ff++/README.md) +* [**KoDF 前処理**](/preprocess/kodf/README.md) + +## 🏗 モデルアーキテクチャ + +**Multi Scale Efficient Global Context Vision Transformer** は、最適化されたマルチスケール・ハイブリッドアーキテクチャです。CNN の空間的帰納バイアス(Spatial Inductive Bias)と階層的アテンション機構を統合することで、精緻な検出のために微細な(Local)アーティファクトと巨視的な(Global)アーティファクトを効果的に識別します。 + +#### 詳細情報 + +- [モデルアーキテクチャ: MS-EffViT](deepguard/MS_EffViT.md) - _**Multi Scale Efficient Vision Transformer**_ +- [発展アーキテクチャ: MS-EFFGCViT](deepguard/MS_EffGCViT.md) - _**Multi Scale Efficient Global Context Vision Transformer**_ + + +

+ +

+ +特徴マップ全体にわたって長距離(Long-range)および短距離(Short-range)の情報を共に捉えるため、2種類のセルフアテンションを活用します。 + +- **Local Window Attention**: 画像サイズに比例する線形計算量を維持しながら、局所的なテクスチャと精密な空間的ディテールを効率的に捉えます。 +- **Global Window Attention**: Swin Transformer とは異なり、このモジュールはローカルウィンドウの Key, Value と相互作用するグローバルクエリ(Global-queries)を用います。これにより各ローカル領域が大域的なコンテキストを取り込み、長距離依存を効果的に把握して空間構造全体の包括的な理解を提供します。 + +

+ +

+ +## 🧬 Model Zoo + +| モデル名 | 解像度 | 総パラメータ(M) | バックボーン(M) | L-ViT(M) | H-ViT(M) | 演算量(FLOPs, G) | 設定ファイル | +| ----- | ---------- | -------------- | ----------- |------------- | ------------- | -------------- | ------- | +| ⚡ ms_eff_gcvit_b0 | 224 X 224 | 8.7 | 3.6(41.4%) | 1.7(19.5%) | 3.3(37.9%) | 0.87 | [spec](deepguard/config/ms_eff_gcvit_b5/celeb_df_v2.yaml) | +| 🔥 ms_eff_gcvit_b5 | 384 X 384 | 50.3 | 27.3(54.3%) | 6.6(13.1%) | 16.1(32.0%) | 13.64 | [spec](deepguard/config/ms_eff_gcvit_b5/celeb_df_v2.yaml) | + +## 🚀 学習 + +`ms_eff_vit` と `ms_eff_gcvit` の両方について学習スクリプトを提供します。無料の GPU 環境として **Google Colab** を、実験の記録およびトラッキングとして **Weights & Biases(W&B)** の使用を推奨します。 + +#### 📊 Weight & Biases 実験結果 + +* **ms_eff_vit_b0:** [Celeb-DF-v2 🚀](https://wandb.ai/origin6165/ms_eff_vit_b0_celeb_df_v2) | [FaceForensics++ 🚀](https://wandb.ai/origin6165/ms_eff_vit_b0_ff++) | [KoDF 🚀](https://wandb.ai/origin6165/ms_eff_vit_b0_kodf) +* **ms_eff_vit_b5:** [Celeb-DF-v2 🚀](https://wandb.ai/origin6165/ms_eff_vit_b5_celeb_df_v2) | [FaceForensics++ 🚀](https://wandb.ai/origin6165/ms_eff_vit_b5_ff++) | [KoDF 🚀](https://wandb.ai/origin6165/ms_eff_vit_b5_kodf) +* **ms_eff_gcvit_b0:** [Celeb-DF-v2 🚀](https://wandb.ai/origin6165/ms_eff_gcvit_b0_celeb_df_v2) | [FaceForensics++ 🚀](https://wandb.ai/origin6165/ms_eff_gcvit_b0_ff++) | [KoDF 🚀](https://wandb.ai/origin6165/ms_eff_gcvit_b0_kodf) +* **ms_eff_gcvit_b5:** [Celeb-DF-v2 🚀](https://wandb.ai/origin6165/ms_eff_gcvit_b5_celeb_df_v2) | [FaceForensics++ 🚀](https://wandb.ai/origin6165/ms_eff_gcvit_b5_ff++) | [KoDF 🚀](https://wandb.ai/origin6165/ms_eff_gcvit_b5_kodf) + +```python +!python -m train_eff_vit \ # または train_eff_gcvit + --root-dir DATA_ROOT \ + --model-ver "ms_eff_vit_b5" \ # ms_eff_vit_b0, ms_eff_vit_b5, ms_eff_gcvit_b0, ms_eff_gcvit_b5 + --dataset "ff++" \ # ff++, celeb_df_v2, kodf + --seed 2025 \ # 再現性のためのシード値 + --wandb-api-key "ご自身のAPIキー" # ご自身のW&B APIキーを入力してください +``` + +## 📈 モデル評価 + +```python +!python -m inference.predict_video \ + --root-dir DATA_ROOT \ + --margin-ratio 0.2 \ + --conf-thres 0.5 \ + --min-face-ratio 0.01 \ + --model-name "ms_eff_gcvit_b0" \ # ms_eff_vit_b0, ms_eff_vit_b5, ms_eff_gcvit_b0, ms_eff_gcvit_b5 + --model-dataset "kodf" \ # ff++, celeb_df_v2, kodf + --num-frames 20 \ + --tta-hflip 0.0 \ + --agg-mode "conf" \ +``` + +**Celeb DF(v2) 事前学習モデル** + +| モデルバージョン | Test@Acc | Test@Auc | Test@log_loss | ダウンロード | 学習レシピ | +| ------------- | -------- | -------- | ---------- | -------- | ----- | +| ms_eff_gcvit_b0 | 0.9842 | 0.9965 | 0.0283 | [model](https://github.com/HanMoonSub/DeepGuard/releases/download/v0.1.0/ms_eff_gcvit_b0_celeb_df_v2.bin) | [recipe](deepguard/config/ms_eff_gcvit_b0/celeb_df_v2.yaml) | +| ms_eff_gcvit_b5 | 0.9981 | 0.9984 | 0.0089 | [model](https://github.com/HanMoonSub/DeepGuard/releases/download/v0.1.0/ms_eff_gcvit_b5_celeb_df_v2.bin) | [recipe](deepguard/config/ms_eff_gcvit_b5/celeb_df_v2.yaml) | + +**FaceForensics++ 事前学習モデル** + +| モデルバージョン | Test@Acc | Test@Auc | Test@log_loss | ダウンロード | 学習レシピ | +| ------------- | -------- | -------- | ---------- | -------- | ------ | +| ms_eff_gcvit_b0 | 0.9808 | 0.9969 | 0.0637| [model](https://github.com/HanMoonSub/DeepGuard/releases/download/v0.1.0/ms_eff_gcvit_b0_ff++.bin) | [recipe](deepguard/config/ms_eff_gcvit_b0/celeb_df_v2.yaml) | +| ms_eff_gcvit_b5 | 0.9850 | 0.9974 | 0.0492 | [model](https://github.com/HanMoonSub/DeepGuard/releases/download/v0.1.0/ms_eff_gcvit_b5_ff++.bin) | [recipe](deepguard/config/ms_eff_gcvit_b5/celeb_df_v2.yaml) | + +**KoDF 事前学習モデル** + +| モデルバージョン | Test@Acc | Test@Auc | Test@log_loss | ダウンロード | 学習レシピ | +| ------------- | -------- | -------- | ---------- | -------- | ------ | +| ms_eff_gcvit_b0 | 0.9655 | 0.9792 | 0.1237 | [model](https://github.com/HanMoonSub/DeepGuard/releases/download/v0.2.0/ms_eff_gcvit_b0_kodf.bin) | [recipe](deepguard/config/ms_eff_gcvit_b0/celeb_df_v2.yaml) | +| ms_eff_gcvit_b5 | 0.9850 | 0.9974 | 0.0492 | [model](https://github.com/HanMoonSub/DeepGuard/releases/download/v0.2.0/ms_eff_gcvit_b5_kodf.bin) | [recipe](deepguard/config/ms_eff_gcvit_b5/celeb_df_v2.yaml) | + +## 💻 モデルの使い方 + +**クイックスタート** +`DeepGuard` パッケージを直接インポートするか、`timm` インターフェースを通じてモデルをロードできます。 + +**対応データセット**: `celeb_df_v2`, `ff++`, `kodf` + +**インストール** + +```bash +# pip install -U git+https://github.com/HanMoonSub/DeepGuard.git +pip install deepguard +``` + + +**方法 A: 直接インポート (DeepGuard 使用)** + +```python +from deepguard import ms_eff_gcvit_b0, ms_eff_gcvit_b5 + +model = ms_eff_gcvit_b0(pretrained=True, dataset="celeb_df_v2") +model = ms_eff_gcvit_b5(pretrained=True, dataset="ff++") +``` + +**方法 B: timm インターフェース使用** + +```python +import timm +import deepguard + +model = timm.create_model("ms_eff_gcvit_b0", pretrained=True, dataset="ff++") +model = timm.create_model("ms_eff_gcvit_b5", pretrained=True, dataset="kodf") +``` + +**方法 C: Hugging Face Hub** + +```python +import torch +from huggingface_hub import hf_hub_download +from deepguard import ms_eff_gcvit_b0 # or ms_eff_gcvit_b5 + +REPO_ID = "KoreaPeter/ms-eff-gcvit-deepfake" + +ckpt = hf_hub_download(REPO_ID, "ms_eff_gcvit_b0_kodf.bin") # celeb_df_v2 | ff++ | kodf +model = ms_eff_gcvit_b0(pretrained=False) +model.load_state_dict(torch.load(ckpt, map_location="cpu")) +model.eval() +``` + +## 🔮 画像と動画の予測 + +#### ディープフェイク画像の予測 + +```python +from inference.image_predictor import ImagePredictor + +# 予測器の初期化 +predictor = ImagePredictor( + margin_ratio = 0.2, # 検出された顔クロップ周辺のマージン比率 + conf_thres = 0.5, # 顔検出の信頼度しきい値 + min_face_ratio = 0.01, # 処理対象とするフレーム比の最小顔サイズ比率 + model_name = "ms_eff_vit_b0", # ms_eff_vit_b5, ms_eff_gcvit_b0, ms_eff_gcvit_b5 から選択 + dataset = "celeb_df_v2" # ff++, kodf データセットから選択 + ) + +# 推論の実行 +result = predictor.predict_img( + img_path="path/to/image.jpg", + tta_hflip=0.0 # テスト時augmentation(TTA)用の水平反転確率 + ) + +print(f"ディープフェイク確率: {result:.4f}") +``` + +#### ディープフェイク動画の予測 + +```python +from inference.video_predictor import VideoPredictor + +# 予測器の初期化 +predictor = VideoPredictor( + margin_ratio = 0.2, # 検出された顔クロップ周辺のマージン比率 + conf_thres = 0.5, # 顔検出の信頼度しきい値 + min_face_ratio = 0.01, # 処理対象とするフレーム比の最小顔サイズ比率 + model_name = "ms_eff_vit_b0", # ms_eff_vit_b5, ms_eff_gcvit_b0, ms_eff_gcvit_b5 から選択 + dataset = "celeb_df_v2" # ff++, kodf データセットから選択 + ) + +# 推論の実行 +result = predictor.predict_video( + video_path = "path/to/video.mp4", + num_frames = 20, # 動画あたりのサンプリングフレーム数 + agg_mode = "conf", # 集約方式: 'conf', 'mean', 'vote' + tta_hflip=0.0 # テスト時augmentation(TTA)用の水平反転確率 + ) + +print(f"ディープフェイク確率: {result:.4f}") +``` + +## 🎨 ディープフェイクAI説明可能性(XAI) + +ディープフェイク検出は、その判断根拠が説明可能であって初めて信頼に値します。DeepGuard は、モデルがどの顔を *どこで、なぜ* 改ざんと判断したのかを可視化する **プロダクションレベルの XAI ツールキット** を統合し、ブラックボックスのスコアを実用的なフォレンジック証拠へと転換します。 + +⭐ ハイブリッド CNN-ViT アーキテクチャ、特に `MS-EffViT` と `MS-EffGCViT` で検証済みです。 +⭐ **デュアルブランチ分析(Dual-Branch Analysis)**: デュアルブランチ設計は、モデル自身のマルチスケール推論方式をそのまま反映します。 + +### 🧠 デュアルブランチ XAI の仕組み + +

+ +

+ +| ブランチ | 特徴マップ | 焦点 | 最適な用途 | +| ------ | ----------- | ----- | -------- | +| ![](https://img.shields.io/badge/Low_level-blue?style=flat-square) | 高解像度 | 局所的な偽造アーティファクト | 肌のテクスチャ、境界のブレンディング、圧縮痕跡 | +| ![](https://img.shields.io/badge/High_level-red?style=flat-square) | 低解像度 | 大域的な意味構造 | 照明の不整合、顔の幾何構造、影のアーティファクト | + +### 📐 XAI 手法 + +各手法は、経験的に最も高い性能を示すブランチに割り当てられています。 + +| ブランチ | 手法 | 🎯 コアアイデア | +| :--- | :--- | :--- | +| **`low level`** | **HiResCAM** | GradCAM に似ているが、活性化(activation)と勾配を要素ごと(element-wise)に乗算する。特定のモデルに対して忠実性(faithfulness)が理論的に保証される | +| **`low level`** | **GradCAMElementWise** | GradCAM に似ているが、活性化と勾配を要素ごとに乗算した後、総和を取る前に ReLU を適用する | +| **`low level`** | **LayerCAM** | 正の勾配で活性化に空間的な重み付けを行う。特に下位層でより良く機能する | +| **`high level`** | **EigenGradCAM** | EigenCAM に似ているがクラス判別性を追加: (活性化×勾配)の第1主成分(First principal component)。GradCAM に近いがよりクリーン | +| **`high level`** | **GradCAM++** | GradCAM に似ているが2次勾配(second order gradients)を使用する | +| **`high level`** | **XGradCAM** | GradCAM に似ているが、正規化された活性化で勾配をスケーリングする | + +- **`aug_smooth`**: CAM を平均化する前に TTA(水平反転)を適用 → より滑らかで対象によく整列したマップを生成 +- **`eigen_smooth`**: PCA ノイズ除去を適用 → 支配的な偽造パターンのみを保持 + +### 💡 ディープフェイク XAI の使い方 + +**Low-Level ブランチ — 局所的アーティファクト検出** + +```python +from explainability import HiResCAMExplainer, GradCAMElementWiseExplainer, LayerCAMExplainer + +explainer = HiResCAMExplainer( + model_name = "ms_eff_gcvit_b0", # ms_eff_vit_b0, ms_eff_gcvit_b5, ms_eff_vit_b5 から選択 + dataset = "celeb_df_v2", # ff++, kodf から選択 + branch_level = "low", +) +``` + +**High-Level ブランチ — 大域的意味構造検出** +```python +from explainability import EigenGradCAMExplainer, GradCAMPlusPlusExplainer, XGradCAMExplainer + +explainer = EigenGradCAMExplainer( + model_name = "ms_eff_gcvit_b0", + dataset = "celeb_df_v2", + branch_level = "high", +) +``` + +### 🎨 可視化モード + +

+ +

+ +**1. Heatmap — 連続的な活性化分布** + +```python +result = explainer.display_heatmap_on_image( + img_path = "path/to/image.jpg", + category = 1, # 0: Real, 1: Fake + threshold = 0.5, # 二値化カットオフ (0.5~1.0)、またはOtsu自動しきい値は "auto" + image_weight = 0.5, # 0.0: ヒートマップのみ ← → 1.0: 元画像のみ + aug_smooth = False, # TTAスムージング ('pro'モデルは非対応) + eigen_smooth = False, # PCAノイズ除去 +) +``` + +**2. Bounding Box — 離散的な偽造領域のローカライズ** + +```python +result = explainer.display_bbox_on_image( + img_path = "path/to/image.jpg", + category = 1, + threshold = 0.5, + thickness = 1, + aug_smooth = False, + eigen_smooth = False, +) +``` + +**3. Heatmap + BBox — 統合オーバーレイ (レポート作成時に推奨)** +```python +result = explainer.display_heatmap_bbox_on_image( + img_path = "path/to/image.jpg", + category = 1, + threshold = 0.5, + image_weight = 0.5, + aug_smooth = False, + eigen_smooth = False, +) +``` + +### 📊 可視化結果 + +

+ + + + + + +
+

+ + +#### MS-EFF-VIT — Low-Level Branch + +| Model | Branch-Level | Image | HiresCam | GradCamElementwise | LayerCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-vit-b0** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | +| **🔥 ms-eff-vit-b5** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | + +#### MS-Eff-ViT — High-Level Branch + +| Model | Branch-Level | Image | EigenGradCam | GradCamPlusPlus | XGradCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-vit-b0** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | +| **🔥 ms-eff-vit-b5** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | + +#### MS-EFF-GCVIT — Low-Level Branch + +| Model | Branch-Level | Image | HiresCam | GradCamElementwise | LayerCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-gcvit-b0** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | +| **🔥 ms-eff-gcvit-b5** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | + +#### MS-Eff-GCViT — High-Level Branch + +| Model | Branch-Level | Image | EigenGradCam | GradCamPlusPlus | XGradCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-gcvit-b0** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | +| **🔥 ms-eff-gcvit-b5** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | + + +## 📬 制作者 + +_**本プロジェクトは、忠北大学校(CBNU)ソフトウェア学部の卒業制作(Senior Graduation Project)として開発されました。**_ + +* **ハン・ムンソプ(한문섭)**: **Data & Backend Engineering** (データ前処理パイプライン、DBスキーマ設計) — [hanmoon3054@gmail.com](mailto:hanmoon3054@gmail.com) +* **イ・イェソル(이예솔)**: **UI/UX & Frontend Engineering** (UI/UXデザイン、ユーザーダッシュボード、モデル可視化) — [yesol4138@chungbuk.ac.kr](mailto:yesol4138@chungbuk.ac.kr) +* **ソ・ユンジェ(서윤제)**: **AI Engineering** (AIモデルアーキテクチャ設計、推論API設計、モデルサービング) — [seoyunje2001@gmail.com](mailto:seoyunje2001@gmail.com) + + +## 📝 参考文献 + +1. [`facenet-pytorch`](https://github.com/timesler/facenet-pytorch) - _Tim Esler による事前学習済み顔検出(MTCNN)および認識(InceptionResNet)モデル_ +2. [`face-cutout`](https://github.com/sowmen/face-cutout) - _Sowmen による Face Cutout ライブラリ_ +3. [`Celeb-DF++`](https://github.com/OUC-VAS/Celeb-DF-PP) - _OUC-VAS Group による Celeb-DF++ データセット_ +4. [`DeeperForensics-1.0`](https://github.com/EndlessSora/DeeperForensics-1.0) - _Endless Sora による DeeperForensics-1.0 データセット_ +5. [`Deepfake Detection`](https://github.com/abhijithjadhav/Deepfake_detection_using_deep_learning) - _Abhijith Jadhav による ResNext と LSTM を用いた動画ディープフェイク検出_ +6. [`deepfake-detection-project-v4`](https://github.com/ameencaslam/deepfake-detection-project-v4) - _Ameen Caslam による複数のディープラーニングモデル_ +7. [`Awesome-Deepfake-Detection`](https://github.com/Daisy-Zhang/Awesome-Deepfakes-Detection) - _Daisy Zhang がまとめたツール・論文・コードのキュレーションリスト_ +8. [`Pytorch-Grad-Cam`](https://github.com/jacobgil/pytorch-grad-cam) - _PyTorch モデルのための高度な視覚的説明ツール_ + +## ⚖️ ライセンス + +本プロジェクトは MIT ライセンスの条件の下で配布されます。 \ No newline at end of file diff --git a/README_KR.md b/README_KR.md index 817282a..02efcb8 100644 --- a/README_KR.md +++ b/README_KR.md @@ -1,14 +1,17 @@ -# Deepfakes Detection (딥페이크 탐지) - -

- DeepGuard Banner -

+# Deepfakes Detection

License Stars + Downloads + Last Commit Status Release + Repo Size +

+ +

+ DeepGuard Banner

@@ -27,6 +30,7 @@

🇺🇸 English Version | + 🇯🇵 日本語版 | 📈 모델 평가 | 🔮 데모 실행

@@ -38,11 +42,12 @@ - [📚 딥페이크 비디오 벤치마크 데이터셋](#-딥페이크-비디오-벤치마크-데이터셋) — 학습에 사용된 Celeb-DF-v2, FF++, KoDF 데이터셋 개요. - [⚙️ 데이터 준비](#데이터-준비) — YOLOv8을 이용한 효율적인 얼굴 검출 및 랜드마크 추출 파이프라인. - [🏗 모델 구조](#-모델-구조) — 하이브리드 CNN-ViT (MS-EffViT & MS-EffGCViT) 설계 상세. -- [🧬 모델 주(Model Zoo)](#-모델-주model-zoo) — 모델 변체별 파라미터 수 및 연산량(FLOPs) 비교. +- [🧬 모델 주(Model Zoo)](#-model-zoo) — 모델 변체별 파라미터 수 및 연산량(FLOPs) 비교. - [🚀 학습](#-학습) - Google Colab 및 W&B를 활용한 단계별 학습 스크립트. - [📈 모델 평가](#-모델-평가) - 벤치마크 결과. - [💻 모델 사용법](#-모델-사용법) - Python 코드 및 timm을 통한 DeepGuard 모델 통합 방법. - [🔮 이미지 및 비디오 예측](#-이미지-및-비디오-예측) - 딥페이크 탐지를 위한 간단한 추론 예시. +- [🎨 딥페이크 AI 설명가능성(XAI)](#-딥페이크-ai-설명가능성xai) - Grad-CAM 및 어텐션 맵을 통한 모델 판단 근거 시각화. - [📬 제작자](#-제작자) - [📝 참고 문헌](#-참고-문헌) - [⚖️ 라이선스](#-라이선스) @@ -61,7 +66,7 @@ pip install -r requirements.txt 저장소를 클론하고 해당 디렉토리로 이동합니다: ```bash -git clone [https://github.com/HanMoonSub/DeepGuard.git](https://github.com/HanMoonSub/DeepGuard.git) +git clone https://github.com/HanMoonSub/DeepGuard.git cd DeepGuard ``` @@ -135,7 +140,7 @@ DATA_ROOT/

- +

특징 맵 전체에 걸쳐 장거리(Long-range) 및 단거리(Short-range) 정보를 모두 캡처하기 위해 두 가지 유형의 셀프 어텐션을 활용합니다. @@ -144,10 +149,10 @@ DATA_ROOT/ - **Global Window Attention**: Swin Transformer와 달리, 이 모듈은 로컬 윈도우의 Key, Value와 상호작용하는 글로벌 쿼리(Global-queries)를 사용합니다. 이를 통해 각 로컬 영역이 전역 컨텍스트를 수용하게 함으로써 장거리 의존성을 효과적으로 파악하고 전체 공간 구조에 대한 포괄적인 이해를 제공합니다.

- +

-## 🧬 모델 주(Model Zoo) +## 🧬 Model Zoo | 모델명 | 해상도 | 총 파라미터(M) | 백본(M) | L-ViT(M) | H-ViT(M) | 연산량(FLOPs, G) | 설정 파일 | | ----- | ---------- | -------------- | ----------- |------------- | ------------- | -------------- | ------- | @@ -220,7 +225,7 @@ DATA_ROOT/ **설치** ```bash -# pip install -U git+[https://github.com/HanMoonSub/DeepGuard.git](https://github.com/HanMoonSub/DeepGuard.git) +# pip install -U git+https://github.com/HanMoonSub/DeepGuard.git pip install deepguard ``` @@ -244,6 +249,21 @@ model = timm.create_model("ms_eff_gcvit_b0", pretrained=True, dataset="ff++") model = timm.create_model("ms_eff_gcvit_b5", pretrained=True, dataset="kodf") ``` +**방법 C: Hugging Face Hub** + +```python +import torch +from huggingface_hub import hf_hub_download +from deepguard import ms_eff_gcvit_b0 # or ms_eff_gcvit_b5 + +REPO_ID = "KoreaPeter/ms-eff-gcvit-deepfake" + +ckpt = hf_hub_download(REPO_ID, "ms_eff_gcvit_b0_kodf.bin") # celeb_df_v2 | ff++ | kodf +model = ms_eff_gcvit_b0(pretrained=False) +model.load_state_dict(torch.load(ckpt, map_location="cpu")) +model.eval() +``` + ## 🔮 이미지 및 비디오 예측 #### 딥페이크 이미지 예측 @@ -294,6 +314,150 @@ result = predictor.predict_video( print(f"딥페이크 확률: {result:.4f}") ``` +## 🎨 딥페이크 AI 설명가능성(XAI) + +딥페이크 탐지는 그 판단 근거가 설명될 수 있을 때 비로소 신뢰받을 수 있습니다. DeepGuard는 모델이 어떤 얼굴을 *어디서, 왜* 조작된 것으로 판단했는지 시각화하는 **프로덕션 레벨의 XAI 툴킷**을 통합하여, 블랙박스 점수를 실질적인 포렌식 증거로 전환합니다. + +⭐ 하이브리드 CNN-ViT 아키텍처, 특히 `MS-EffViT`와 `MS-EffGCViT`에서 검증되었습니다. +⭐ **듀얼 브랜치 분석(Dual-Branch Analysis)**: 듀얼 브랜치 설계는 모델 자체의 멀티 스케일 추론 방식을 그대로 반영합니다. + +### 🧠 듀얼 브랜치 XAI 작동 원리 + +

+ +

+ +| 브랜치 | 특징 맵 | 초점 | 최적 용도 | +| ------ | ----------- | ----- | -------- | +| ![](https://img.shields.io/badge/Low_level-blue?style=flat-square) | 고해상도 | 국부적 위조 아티팩트 | 피부 질감, 경계 블렌딩, 압축 흔적 | +| ![](https://img.shields.io/badge/High_level-red?style=flat-square) | 저해상도 | 전역 의미 구조 | 조명 불일치, 얼굴 기하 구조, 그림자 아티팩트 | + +### 📐 XAI 기법 + +각 기법은 경험적으로 가장 좋은 성능을 보이는 브랜치에 배정되었습니다. + +| 브랜치 | 기법 | 🎯 핵심 아이디어 | +| :--- | :--- | :--- | +| **`low level`** | **HiResCAM** | GradCAM과 유사하나 활성화(activation)와 그래디언트를 요소별(element-wise)로 곱함. 특정 모델에 대해 충실도(faithfulness)가 이론적으로 보장됨 | +| **`low level`** | **GradCAMElementWise** | GradCAM과 유사하나 활성화와 그래디언트를 요소별로 곱한 뒤, 합산 전에 ReLU를 적용 | +| **`low level`** | **LayerCAM** | 양의 그래디언트로 활성화에 공간적 가중치를 부여. 특히 하위 레이어에서 더 잘 작동 | +| **`high level`** | **EigenGradCAM** | EigenCAM과 유사하나 클래스 판별력을 추가: (활성화×그래디언트)의 제1주성분(First principal component). GradCAM과 비슷하면서도 더 깔끔 | +| **`high level`** | **GradCAM++** | GradCAM과 유사하나 2차 그래디언트(second order gradients)를 사용 | +| **`high level`** | **XGradCAM** | GradCAM과 유사하나 정규화된 활성화로 그래디언트를 스케일링 | + +- **`aug_smooth`**: CAM을 평균화하기 전에 TTA(수평 뒤집기)를 적용 → 더 부드럽고 객체에 잘 정렬된 맵 생성 +- **`eigen_smooth`**: PCA 노이즈 제거를 적용 → 지배적인 위조 패턴만 유지 + +### 💡 딥페이크 XAI 사용법 + +**Low-Level 브랜치 — 국부적 아티팩트 탐지** + +```python +from explainability import HiResCAMExplainer, GradCAMElementWiseExplainer, LayerCAMExplainer + +explainer = HiResCAMExplainer( + model_name = "ms_eff_gcvit_b0", # ms_eff_vit_b0, ms_eff_gcvit_b5, ms_eff_vit_b5 중 선택 + dataset = "celeb_df_v2", # ff++, kodf 중 선택 + branch_level = "low", +) +``` + +**High-Level 브랜치 — 전역 의미 구조 탐지** +```python +from explainability import EigenGradCAMExplainer, GradCAMPlusPlusExplainer, XGradCAMExplainer + +explainer = EigenGradCAMExplainer( + model_name = "ms_eff_gcvit_b0", + dataset = "celeb_df_v2", + branch_level = "high", +) +``` + +### 🎨 시각화 모드 + +

+ +

+ +**1. Heatmap — 연속적인 활성화 분포** + +```python +result = explainer.display_heatmap_on_image( + img_path = "path/to/image.jpg", + category = 1, # 0: Real, 1: Fake + threshold = 0.5, # 이진화 컷오프 (0.5~1.0), 또는 Otsu 자동 임계값은 "auto" + image_weight = 0.5, # 0.0: 히트맵만 ← → 1.0: 원본만 + aug_smooth = False, # TTA 스무딩 ('pro' 모델은 미지원) + eigen_smooth = False, # PCA 노이즈 제거 +) +``` + +**2. Bounding Box — 이산적인 위조 영역 지역화** + +```python +result = explainer.display_bbox_on_image( + img_path = "path/to/image.jpg", + category = 1, + threshold = 0.5, + thickness = 1, + aug_smooth = False, + eigen_smooth = False, +) +``` + +**3. Heatmap + BBox — 통합 오버레이 (리포팅 시 권장)** +```python +result = explainer.display_heatmap_bbox_on_image( + img_path = "path/to/image.jpg", + category = 1, + threshold = 0.5, + image_weight = 0.5, + aug_smooth = False, + eigen_smooth = False, +) +``` + +### 📊 시각화 결과 + +

+ + + + + + +
+

+ + +#### MS-EFF-VIT — Low-Level Branch + +| Model | Branch-Level | Image | HiresCam | GradCamElementwise | LayerCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-vit-b0** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | +| **🔥 ms-eff-vit-b5** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | + +#### MS-Eff-ViT — High-Level Branch + +| Model | Branch-Level | Image | EigenGradCam | GradCamPlusPlus | XGradCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-vit-b0** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | +| **🔥 ms-eff-vit-b5** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | + +#### MS-EFF-GCVIT — Low-Level Branch + +| Model | Branch-Level | Image | HiresCam | GradCamElementwise | LayerCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-gcvit-b0** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | +| **🔥 ms-eff-gcvit-b5** | ![](https://img.shields.io/badge/Low_level_Branch-blue?style=flat-square) | | | | | + +#### MS-Eff-GCViT — High-Level Branch + +| Model | Branch-Level | Image | EigenGradCam | GradCamPlusPlus | XGradCam | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **⚡ ms-eff-gcvit-b0** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | +| **🔥 ms-eff-gcvit-b5** | ![](https://img.shields.io/badge/High_level_Branch-red?style=flat-square) | | | | | + ## 📬 제작자 @@ -310,11 +474,11 @@ _**본 프로젝트는 충북대학교(CBNU) 소프트웨어학부 졸업 작품 2. [`face-cutout`](https://github.com/sowmen/face-cutout) - _Sowmen의 Face Cutout 라이브러리_ 3. [`Celeb-DF++`](https://github.com/OUC-VAS/Celeb-DF-PP) - _OUC-VAS Group의 Celeb-DF++ 데이터셋_ 4. [`DeeperForensics-1.0`](https://github.com/EndlessSora/DeeperForensics-1.0) - _Endless Sora의 DeeperForensics-1.0 데이터셋_ -5. [`Deepfake Detection`](https://github.com/abhijithjadhav/Deepfake_detection_using_deep_learning) - _Abhijith Jadhav의 ResNext와 LSTM을 이용한 딥페이크 탐지_ -6. [`deepfake-detection-project-v4`](https://github.com/ameencaslam/deepfake-detection-project-v4) - _Ameen Caslam의 다양한 딥러닝 모델들_ -7. [`Awesome-Deepfake-Detection`](https://github.com/Daisy-Zhang/Awesome-Deepfakes-Detection -) - _Daisy Zhang이 정리한 도구, 논문 및 코드 리스트_ +5. [`Deepfake Detection`](https://github.com/abhijithjadhav/Deepfake_detection_using_deep_learning) - _Abhijith Jadhav의 ResNext와 LSTM을 이용한 비디오 딥페이크 탐지_ +6. [`deepfake-detection-project-v4`](https://github.com/ameencaslam/deepfake-detection-project-v4) - _Ameen Caslam의 다중 딥러닝 모델_ +7. [`Awesome-Deepfake-Detection`](https://github.com/Daisy-Zhang/Awesome-Deepfakes-Detection) - _Daisy Zhang이 정리한 도구, 논문, 코드 큐레이션 목록_ +8. [`Pytorch-Grad-Cam`](https://github.com/jacobgil/pytorch-grad-cam) - _PyTorch 모델을 위한 고급 시각적 설명 도구_ ## ⚖️ 라이선스 -본 프로젝트는 MIT 라이선스의 규정에 따라 라이선스가 부여됩니다. \ No newline at end of file +본 프로젝트는 MIT 라이선스 조건에 따라 배포됩니다. \ No newline at end of file diff --git a/Videos/display_img.py b/Videos/display_img.py deleted file mode 100644 index e412e12..0000000 --- a/Videos/display_img.py +++ /dev/null @@ -1,46 +0,0 @@ -import os -import argparse -import cv2 - -# ------------------------------- -# Display specific frame -# ------------------------------- -def display_img(video_path: str, frame_number: int) -> None: - cap = cv2.VideoCapture(video_path) - if not cap.isOpened(): - raise FileNotFoundError(f"Could not open video file: {video_path}") - - total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - if frame_number < 1 or frame_number > total_frames: - cap.release() - raise ValueError(f"Frame number {frame_number} is out of range (1-{total_frames})") - - cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1) - ret, frame = cap.read() - cap.release() - if not ret or frame is None: - raise RuntimeError(f"Failed to read frame {frame_number} from {video_path}") - - cv2.imshow(f"Frame {frame_number}/{total_frames}", frame) - print("Press any key to close window...") - cv2.waitKey(0) - cv2.destroyAllWindows() - -# ------------------------------- -# Main -# ------------------------------- -def main(): - parser = argparse.ArgumentParser(description="Play a video or display a specific frame") - parser.add_argument("--video_path", type=str, required=True, help="Path to video file") - parser.add_argument("--frame", type=int, default=1, help="Frame number in Video") - - args = parser.parse_args() - print(args) - - if not os.path.exists(args.video_path): - raise FileNotFoundError(f"Video file not found: {args.video_path}") - - display_img(args.video_path, args.frame) - -if __name__ == "__main__": - main() diff --git a/Videos/display_video.py b/Videos/display_video.py deleted file mode 100644 index bbdab81..0000000 --- a/Videos/display_video.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -import argparse -import cv2 - -# ------------------------------- -# Display whole video -# ------------------------------- - -## cv2.CAP_PROP_FPS: Video Frame per Second -## cv2.CAP_PROP_FRAME_COUNT: Video Total Frames -## cv2.CAP_PROP_FRAME_HEIGHT: Frame Height -## cv2.CAP_PROP_FRAME_WIDTH: Frame Width - -def display_video(video_path: str, resize_width: int = None) -> None: - cap = cv2.VideoCapture(video_path) - if not cap.isOpened(): - raise FileNotFoundError(f"Could not open video file: {video_path}") - - fps = cap.get(cv2.CAP_PROP_FPS) - total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - delay = int(1000 / fps) if fps > 0 else 30 - - h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - - print(f"🎬 Playing video: {video_path}") - print(f"📸 FPS: {fps:.2f}, Total_Frames: {total_frames}, Delay: {delay}ms") - print(f"📸 Frame Width: {w}, Frame Height: {h}") - print("Press 'q' to stop playback.") - - while True: - ret, frame = cap.read() - if not ret: - print("✅ Video playback finished.") - break - - if resize_width is not None: - new_h = int(resize_width * h / w) - frame = cv2.resize(frame, (resize_width, new_h)) - - cv2.imshow("Video Playback", frame) - - if cv2.waitKey(delay) & 0xFF == ord('q'): - print("🛑 Video stopped by user.") - break - - cap.release() - cv2.destroyAllWindows() - -# ------------------------------- -# Main -# ------------------------------- -def main(): - parser = argparse.ArgumentParser(description="Play a video or display a specific frame") - parser.add_argument("--video_path", type=str, required=True, help="Path to video file") - parser.add_argument("--width", type=int, default=None, help="Resize width (optional, used only in 'video' mode')") - args = parser.parse_args() - - print(args) - - if not os.path.exists(args.video_path): - raise FileNotFoundError(f"Video file not found: {args.video_path}") - - display_video(args.video_path, args.width) - -if __name__ == "__main__": - main() diff --git a/Videos/sample1.mp4 b/Videos/sample1.mp4 deleted file mode 100644 index 6dd65e4..0000000 Binary files a/Videos/sample1.mp4 and /dev/null differ diff --git a/Videos/sample2.mp4 b/Videos/sample2.mp4 deleted file mode 100644 index d9abaca..0000000 Binary files a/Videos/sample2.mp4 and /dev/null differ diff --git a/Videos/sample3.mp4 b/Videos/sample3.mp4 deleted file mode 100644 index 3db3330..0000000 Binary files a/Videos/sample3.mp4 and /dev/null differ diff --git a/client/package.json b/client/package.json index 04bacdc..0c94b5d 100644 --- a/client/package.json +++ b/client/package.json @@ -37,5 +37,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "http-proxy-middleware": "^3.0.5" } } diff --git a/client/public/samples/images/asian_fake_1.JPG b/client/public/samples/images/asian_fake_1.JPG new file mode 100644 index 0000000..c835a50 Binary files /dev/null and b/client/public/samples/images/asian_fake_1.JPG differ diff --git a/client/public/samples/images/asian_fake_2.JPG b/client/public/samples/images/asian_fake_2.JPG new file mode 100644 index 0000000..2e5cb74 Binary files /dev/null and b/client/public/samples/images/asian_fake_2.JPG differ diff --git a/Images/deepfake/image/western_fake_1.JPG b/client/public/samples/images/western_fake_1.JPG similarity index 100% rename from Images/deepfake/image/western_fake_1.JPG rename to client/public/samples/images/western_fake_1.JPG diff --git a/Images/deepfake/image/western_fake_2.JPG b/client/public/samples/images/western_fake_2.JPG similarity index 100% rename from Images/deepfake/image/western_fake_2.JPG rename to client/public/samples/images/western_fake_2.JPG diff --git a/client/public/samples/videos/western_fake.mp4 b/client/public/samples/videos/western_fake.mp4 new file mode 100644 index 0000000..9b6acc0 Binary files /dev/null and b/client/public/samples/videos/western_fake.mp4 differ diff --git a/client/public/samples/videos/western_real.mp4 b/client/public/samples/videos/western_real.mp4 new file mode 100644 index 0000000..05de891 Binary files /dev/null and b/client/public/samples/videos/western_real.mp4 differ diff --git a/client/src/App.js b/client/src/App.js index c708ca9..bb727dc 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -8,8 +8,11 @@ import RegisterPage from './pages/signuppage'; import AnalysisPage from './pages/analysispage'; import AnalysisDetailPage from './pages/AnalysisDetailPage'; import VideoAnalysisPage from './pages/VideoAnalysisPage'; +import VideoTimelinePage from './pages/VideoTimelinePage'; +import HeatmapPage from './pages/HeatmapPage'; +import ImageHeatmapPage from './pages/ImageHeatmapPage'; + -axios.defaults.withCredentials = true; function App() { const [sessionUser, setSessionUser] = useState(null); @@ -19,7 +22,6 @@ function App() { const checkSession = async () => { try { const response = await axios.get('/auth/check'); - if (response.data && response.data.user) { setSessionUser(response.data.user); } @@ -42,6 +44,7 @@ function App() { } /> + } /> } /> @@ -49,6 +52,14 @@ function App() { } /> } /> + + } /> + + } /> + + } /> + + } /> } /> } /> diff --git a/client/src/index.js b/client/src/index.js index 198f664..58fd27e 100644 --- a/client/src/index.js +++ b/client/src/index.js @@ -6,7 +6,8 @@ import reportWebVitals from './reportWebVitals'; import axios from 'axios'; axios.defaults.withCredentials = true; -axios.defaults.baseURL = process.env.REACT_APP_API_URL; +axios.defaults.baseURL = '/api'; +axios.defaults.headers.common['ngrok-skip-browser-warning'] = 'true'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( diff --git a/client/src/pages/AnalysisDetailPage.js b/client/src/pages/AnalysisDetailPage.js index bdc6c08..cbc7546 100644 --- a/client/src/pages/AnalysisDetailPage.js +++ b/client/src/pages/AnalysisDetailPage.js @@ -1,22 +1,61 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -const apiUrl = process.env.REACT_APP_API_URL; +import axios from 'axios'; + const AnalysisDetailPage = ({ sessionUser }) => { - const { state: data } = useLocation(); + const { state } = useLocation(); const navigate = useNavigate(); - if (!data) return
데이터를 찾을 수 없습니다.
; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); - /* 수치 추출 로직(백엔드 DB에서 사용하는 컬럼명과 API 응답 객체 이름을 모두 매핑하기) */ - const prob = data.prob ?? data.score ?? data.analysis?.prob ?? -1; - - // 백엔드 DB 컬럼명인 face_conf, face_ratio, face_brightness 최우선으로 체크 + useEffect(() => { + const fetchDetail = async () => { + const imageId = state?.image_id; + const videoId = state?.video_id; + + if (!imageId && !videoId) { setLoading(false); return; } + + try { + if (imageId) { + const res = await axios.get(`/image/history/${imageId}`); + setData(res.data.context); + } else { + const res = await axios.get(`/video/history/${videoId}`); + setData(res.data.context); + } + } catch (e) { + console.error("상세 조회 실패", e); + } finally { + setLoading(false); + } + }; + fetchDetail(); + }, [state]); + + if (loading) { + return ( +
+

불러오는 중...

+
+ ); + } + + if (!data) { + return ( +
+

분석 데이터를 찾을 수 없거나 비정상적인 접근입니다.

+ +
+ ); + } + + const prob = data.prob ?? data.score ?? data.analysis?.prob ?? data.result_prob ?? -1; const face_conf = data.face_conf ?? data.face_confidence ?? data.conf ?? data.analysis?.face_conf ?? 0; const face_ratio = data.face_ratio ?? data.ratio ?? data.analysis?.face_ratio ?? 0; const face_brightness = data.face_brightness ?? data.brightness ?? data.analysis?.face_brightness ?? 0; - // 라벨 결정 로직 let label = 'UNKNOWN'; if (data.label && data.label !== 'UNKNOWN') { label = data.label.toUpperCase(); @@ -24,80 +63,169 @@ const AnalysisDetailPage = ({ sessionUser }) => { label = prob > 0.5 ? 'FAKE' : 'REAL'; } - // 확률이 -1인 경우 (데이터 로드 실패) N/A 표시를 위해 isInvalid 설정 const isInvalid = prob === -1; const displayProb = isInvalid ? 'N/A' : (Number(prob) * 100).toFixed(1) + '%'; const brightnessColor = face_brightness < 20 ? '#FF4B4B' : '#39FF14'; const ratioColor = face_ratio >= 3 ? '#39FF14' : '#FF4B4B'; - const mediaLoc = data.video_loc || data.image_loc || ''; - const isVideo = !!data.video_loc || (data.score !== undefined && !data.image_loc); + const mediaLoc = data.video_loc || data.media_loc || data.image_loc || ''; + const mediaSrc = mediaLoc.startsWith('blob') ? mediaLoc : mediaLoc; + const isVideo = !!data.video_loc || (data.score !== undefined && !data.image_loc) || window.location.pathname.includes('video'); + + const isWarning = data.status?.toUpperCase() === 'WARNING'; + + const handleImageHeatmap = () => { + if (!sessionUser) { + alert("로그인이 필요한 기능이에요."); + return; + } + + navigate('/image-heatmap', { + state: { + image_id: data.image_id, + image_loc: data.image_loc, + model_type: data.model_type || 'fast', + prob, + label, + }, + }); + }; + + const handleVideoHeatmap = () => { + if (!sessionUser) { + alert("로그인이 필요한 기능이에요."); + return; + } + navigate('/video-timeline', { + state: { + ...data + } + }); + }; + return ( -
+
+ + {/* 상단 네비게이션 헤더 바 */}
-
+
- - {data.version_type?.toUpperCase() || 'V1'} - - - {data.domain_type || '서양인'} - + {data.version_type?.toUpperCase() || 'V1'} + {data.domain_type || '서양인'} + {data.model_type?.toUpperCase() || 'FAST'}
- {sessionUser && ( -
- 분석 담당: {sessionUser.name} -
- )}
-
-
- {/* 비디오 결과이면서 video_loc가 있을 경우 비디오 태그, 그 외엔 이미지 태그 */} - {data.score !== undefined && data.image_loc ? ( -