diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a6fabe0..f6e01d0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,7 @@ on: branches: [master] pull_request: branches: [master, develop] + workflow_dispatch: jobs: # ───────────────────────────────────────── @@ -82,16 +83,50 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | + # 폴더가 없으면 생성 후 클론, 있으면 진입 + if [ ! -d "~/GateGuard" ]; then + git clone https://github.com/CHOSOOGEUN/GateGuard.git ~/GateGuard + fi + cd ~/GateGuard - # 최신 코드 pull - git pull origin master + # 최신 코드 강제 동기화 (충돌 방지) + git fetch origin master + git reset --hard origin/master - # 컨테이너 재시작 - sudo docker compose down - sudo docker compose up -d --build + # 🔐 GitHub Secrets를 실제 서버의 .env로 주입 (S3 실체화) + cat << EOF > backend/.env + AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION=${{ secrets.AWS_REGION }} + AWS_S3_BUCKET=${{ secrets.AWS_S3_BUCKET }} + SECRET_KEY=${{ secrets.SECRET_KEY }} + MASTER_TOKEN=${{ secrets.MASTER_TOKEN }} + ALGORITHM=HS256 + POSTGRES_USER=gateguard + POSTGRES_PASSWORD=gateguard + POSTGRES_DB=gateguard + DATABASE_URL=postgresql+asyncpg://gateguard:gateguard@db:5432/gateguard + REDIS_URL=redis://redis:6379/0 + CELERY_BROKER_URL=redis://redis:6379/0 + CELERY_RESULT_BACKEND=redis://redis:6379/0 + DEBUG=False + EOF - # DB 마이그레이션 - sudo docker exec gateguard-backend-1 alembic upgrade head + # 컨테이너 재시작 및 최적화 + sudo docker compose pull + sudo docker compose up -d --build --remove-orphans + + # DB 마이그레이션 (컨테이너 가동 대기 후 실행) + sleep 5 + sudo docker compose exec -T backend alembic upgrade head echo "✅ 배포 완료: $(date)" + + - name: Notify Deployment Success + if: success() + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d "{\"content\": \"🚀 **GateGuard 배포 성공!**\n🔗 접속 주소: \n📅 완료 시간: $(date)\"}" \ + ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/README.md b/README.md index 6c20fc7..edb5c3c 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ # 🛡️ GateGuard > **지하철 개찰구 AI 기반 무임승차 실시간 감지 시스템** -> 경기대학교 AI컴퓨터공학부 캡스톤디자인 2026 — Milestone v1.0 (Core Infrastructure Complete) +> 경기대학교 AI컴퓨터공학부 캡스톤디자인 2026 — +> Milestone v2.0 (Infra & Automatic Deployment Complete) 🏁 --- ## 📌 프로젝트 소개 지하철 무임승차는 연간 **4,135억 원**의 손실을 유발하는 사회적 문제입니다. -GateGuard는 기존 역무원의 육안 감시를 대체하여, **CCTV 영상을 AI가 실시간 분석**하고 무임승차(뒤따라 들어오기, 점점, 비상문 이용 등)를 자동 감지해 **관리자 대시보드에 즉시 실시간 알림(WebSocket)**을 전송하는 최첨단 보안 시스템입니다. +GateGuard는 기존 역무원의 육안 감시를 대체하여, **CCTV 영상을 AI가 실시간 분석**하고 무임승차(뒤따라 들어오기, 점프, 비상문 이용 등)를 자동 감지해 **관리자 대시보드에 즉시 실시간 알림(WebSocket)**을 전송하는 최첨단 보안 시스템입니다. --- ## 🏗️ 시스템 구조 -``` +```text CCTV 영상 입력 (실시간 스트림) ↓ 비식별화 (Edge/Server) — 얼굴 감지 및 즉시 블러 처리 @@ -35,28 +36,31 @@ Supervision — 개찰구 라인 크로싱 및 구역(Zone) 통과 감지 ## 🔧 기술 스택 ### AI / ML + | 기술 | 역할 | -|------|------| +| :--- | :--- | | YOLOv11 (Ultralytics) | 실시간 사람 감지 | -| ByteTrack | 다중 객체 ID 추적 | +| ByteTrack | 다중 객체 ID 추적 (v0.27.0) | | Supervision | 라인 크로싱 / 구역 통과 감지 | -| OpenCV | 영상 전처리 및 비식별화 | +| OpenCV | 영상 전처리 및 비식별화 (Gaussian Blur) | ### Backend (Milestone v1.0 구축 완료) + | 기술 | 역할 | -|------|------| +| :--- | :--- | | Python 3.11 / FastAPI | 고성능 비동기 REST API 서버 | | **Alembic** | 데이터베이스 마이그레이션 및 버전 관리 | -| **OAuth2 / JWT** | 관리자 권한 기반 보안 체계 (| +| **OAuth2 / JWT** | 관리자 권한 기반 보안 체계 | | **WebSocket** | 실시간 감지 알림 브로드캐스트 | | Celery + Redis | 비동기 영상 업로드 및 알림 작업 | -| PostgreSQL + TimescaleDB | 시계열 이벤트 데이터 저장 | +| PostgreSQL + TimescaleDB | 시계열 이벤트 데이터 저장 및 압축 | --- ## 🚀 개발 환경 실행 (Quick Start) ### 사전 준비 + - Docker Desktop 설치 및 실행 - Python 3.11+ (백엔드 로컬 테스트용) @@ -71,11 +75,12 @@ cd GateGuard docker-compose up -d # 3. 데이터베이스 마이그레이션 (중요!) -# 컨테이너 안에서 Alembic을 통해 최신 스카마를 적용합니다. +# 컨테이너 안에서 Alembic을 통해 최신 스키마를 적용합니다. docker exec gateguard-backend-1 alembic upgrade head -# 4. API 문서 접속 -# 주소: http://localhost:8000/docs +# 4. API 문서 접속 (Swagger) +# 로컬: http://localhost:8000/docs +# 서버: https://gateguardsystems.com/docs ``` --- @@ -83,11 +88,13 @@ docker exec gateguard-backend-1 alembic upgrade head ## 📡 개발자 연동 가이드 ### 1. 실시간 알림 (Websocket) + - **엔드포인트**: `ws://localhost:8000/ws/events` - **수신 타입**: `{"type": "NEW_EVENT", "data": {...}}` - **담당 파트**: 지현(Frontend Dashboard) 연동용 ### 2. 관리자 인증 (JWT) + - **인증 방식**: Bearer Token (Bearer ) - **획득 경로**: `POST /api/auth/login` (Admin 계정 필요) - **대상**: Cameras, Events 전체 조회/수정 API @@ -98,21 +105,24 @@ docker exec gateguard-backend-1 alembic upgrade head - `master`: 배포 전용 (직접 push 금지) - `develop`: 통합 개발 브랜치 -- `feature/담당자-기능명`: 개별 기능 전용 브랜드 +- `feature/담당자-기능명`: 개별 기능 전용 브랜치 -> **[현재 상태]** `feature/조수근-backend-complete-v1` 코드를 `master` 브랜치에 **최종 병합 완료 (Milestone v1.0 정복)** 🏁 +> **[현재 상태]** Milestone 1.0 (Core & AI Integration) **전격 완수 (2026-04-01)** 🏁 +> - 백엔드 코어 인프라 & 보안 체계 구축 완료 (조수근) +> - AI 파이프라인(ByteTrack + Face Anonymization) 통합 완료 (윤효정) +> - TimescaleDB 시계열 최적화 및 고속 조회 시스템 구축 완료 🛡️ --- ## 👥 팀원 구성 | 이름 | 역할 | 담당 파트 | -|------|------|------| -| **조수근** | **백엔드 팀장** | **코어 인프라 · 보안 · 실시간 시스템** | -| **김민지** | **프로젝트 팀장** | **DB 설계 · 마이그레이션** | +| :--- | :--- | :--- | +| **조수근** | **백엔드 팀장** | **코어 인프라 · 보안 · 실시간 시스템 · AI 보조** | +| **김민지** | **프로젝트 팀장** | **DB 설계 · 마이그레이션 · 협업 관리** | | **이동근** | 인프라 | 서버 구축 · CI/CD | | **최태양** | 인프라 | 클라우드 인프라 | -| **이지현** | **프론트팀장** | **대시보드 UI/UX** | +| **이지현** | **프론트팀장** | **대시보드 UI/UX 설계** | | **김유진** | 프론트엔드 | 클라이언트 통계 | | **양은혜** | 프론트엔드 | 모바일 앱 | | **윤효정** | AI | 탐지 모델 구축 | diff --git a/TODO_Milestone_1.md b/TODO_Milestone_1.md deleted file mode 100644 index 17d5a33..0000000 --- a/TODO_Milestone_1.md +++ /dev/null @@ -1,63 +0,0 @@ -# GateGuard — 화요일(4/1)까지 목표 - ---- - -## 조수근 (백엔드) 🏆 [Milestone v1.0 완료] -- [x] `backend/.env` 작성 후 `docker-compose up -d` → 서버 정상 실행 확인 (완료: 2026-03-27) -- [x] `http://localhost:8000/docs` 에서 전 엔드포인트 동작 확인 (정상 응답 확인) -- [x] Alembic 세팅 → `alembic upgrade head` 로 테이블 생성 (**완료: 2026-03-27**) -- [x] `get_current_admin` 의존성 함수 작성 → 주요 라우트에 JWT 인증 적용 (**완료: 2026-03-27**) -- [x] 이벤트 생성 시 `manager.broadcast()` 호출 → WebSocket 실시간 전송 연결 (**완료: 2026-03-27**) -- [x] **백엔드 코어 인프라 통합 완료 및 master 병합** 🚀 (완료: 2026-03-27) - ---- - -## 김민지 (DB) -- [ ] DB 컨테이너 접속 후 TimescaleDB 확장 활성화 + `events` 하이퍼테이블 변환 -- [x] Alembic `env.py` async 엔진 연결 (조수근과 함께 완료: 2026-03-27) -- [ ] `seed.py` 작성 → 관리자 계정 + 테스트 카메라 데이터 삽입 -- [ ] Swagger에서 로그인 → 이벤트 생성까지 흐름 확인 - ---- - -## 이동근 · 최태양 (인프라) 🚀 [완료] -- [x] AWS EC2 생성 (Ubuntu 24.04 LTS) + 보안 그룹 포트 설정 (22, 80, 8000) (완료: 2026-03-28) -- [x] EC2 서버 환경에 Docker + Docker Compose 설치 완료 -- [x] `docker-compose up -d --build` 실행 → EC2 위에서 백엔드 앱 가동 확인 -- [x] `http://15.135.92.86:8000/docs` 접속 확인 및 팀 공유 성공 -- [x] **GitHub Actions CI/CD 파이프라인 구축 및 자동 배포 연동** (완료: 2026-03-28) - -> Nginx, HTTPS는 다음 주에 (CI/CD는 이번 주에 미리 해결!) - ---- - -## 이지현 · 김유진 · 양은혜 (프론트엔드) -- [ ] Vite + React + TypeScript + Tailwind + Shadcn UI 초기 세팅 -- [ ] axios 인스턴스 + React Router 기본 설정 -- [ ] 로그인 페이지 → `POST /api/auth/login` 연결 + 토큰 저장 -- [ ] 로그인 후 대시보드 레이아웃 (사이드바 + 헤더) 구성 - -> 이벤트/카메라 페이지는 다음 주에 - ---- - -## 윤효정 (AI) -- [ ] `pip install ultralytics supervision opencv-python httpx` 설치 -- [ ] `python ai/inference.py` 실행 → 웹캠에서 사람 감지 + 바운딩 박스 확인 -- [ ] 같은 사람 이동 시 tracker ID 유지되는지 확인 -- [ ] 얼굴 블러 동작 확인 -- [ ] 테스트 영상 기준으로 Line Crossing 좌표 맞게 조정 - -> 데이터셋 다운로드 신청은 병행으로 걸어두기 (승인 오래 걸림) - ---- - -## 브랜치 인프라 -``` -develop -├── feature/조수근-backend-complete-v1 <-- 🏁 통합 완료 -├── feature/김민지-db-migration -├── feature/이동근-infra-cicd -├── feature/이지현-dashboard-ui -└── feature/윤효정-yolo-pipeline -``` diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1 @@ + diff --git a/ai/anonymizer.py b/ai/anonymizer.py index 854b707..18a3216 100644 --- a/ai/anonymizer.py +++ b/ai/anonymizer.py @@ -6,9 +6,17 @@ class FaceAnonymizer: def __init__(self, model_path: str = "yolov11n-face.pt"): - self.model = YOLO(model_path) + try: + self.model = YOLO(model_path) + self._enabled = True + except Exception as e: + print(f"[WARNING] 얼굴 비식별화 모델 로드 실패 ({e}) — 비식별화 비활성화") + self.model = None + self._enabled = False def blur(self, frame: np.ndarray) -> np.ndarray: + if not self._enabled: + return frame results = self.model(frame, verbose=False)[0] for box in results.boxes.xyxy.cpu().numpy().astype(int): x1, y1, x2, y2 = box diff --git a/ai/colab/run_pipeline8.ipynb b/ai/colab/run_pipeline8.ipynb new file mode 100644 index 0000000..0c5f6a5 --- /dev/null +++ b/ai/colab/run_pipeline8.ipynb @@ -0,0 +1,800 @@ +{ + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "accelerator": "GPU", + "colab": { + "provenance": [], + "gpuType": "T4" + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "cells": [ + { + "cell_type": "markdown", + "id": "md-title", + "metadata": {}, + "source": [ + "# GateGuard — Rule-based Event Pipeline v8\n\n**v8 변경사항**\n- Fix 1: GT 라벨을 파일명이 아닌 **상위 폴더명** 기준으로 수정\n- Fix 2: `run_pipeline()` 파라미터 하드코딩 제거 (gate_conf / near_gate_margin / jump_multiplier 등)\n- Fix 3: `CONFIG_DICT_V2`에 crawl/tailgating 파라미터 전부 반영\n- Fix 4: verbose_rules=True 시 rule별 탈락 사유 + 진단 로그 출력\n- EfficientNet 2차 검증 (v7 유지)" + ] + }, + { + "cell_type": "code", + "id": "cell-install", + "metadata": {}, + "source": "!pip install -q ultralytics supervision opencv-python-headless tqdm", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-drive", + "metadata": {}, + "source": [ + "## 1. Drive 마운트 & 경로 설정" + ] + }, + { + "cell_type": "code", + "id": "cell-drive", + "metadata": {}, + "source": [ + "import os, shutil, json\nfrom google.colab import drive\ndrive.mount('/content/drive')\n\nDRIVE_ROOT = '/content/drive/MyDrive/ㅈㅎㅊ'\n\nVIDEO_FOLDERS = [\n f'{DRIVE_ROOT}/변환 완료',\n f'{DRIVE_ROOT}/직접 촬영',\n]\n\nGATE_MODEL_PATH = f'{DRIVE_ROOT}/gate_best.pt'\nFACE_MODEL_PATH = f'{DRIVE_ROOT}/yolov11n-face.pt'\nOUTPUT_DIR = '/content/pipeline_output'\nDRIVE_SAVE = f'{DRIVE_ROOT}/pipeline_output'\nCLIPS_DIR = f'{OUTPUT_DIR}/clips'\nCHECKPOINT = None\nEFFICIENTNET_PATH = f\"/content/drive/MyDrive/GateGuard_runs/best_model.pth\"\nGATE_CACHE_FRAMES = 60\n\n# 포함할 하위 폴더명 (영어 이름만)\nINCLUDE_SUBFOLDERS = {'crawling', 'jump', 'nomal', 'normal', 'tailgating'}\nMAX_FILES_PER_CLASS = 20\n\nos.makedirs(OUTPUT_DIR, exist_ok=True)\nos.makedirs(CLIPS_DIR, exist_ok=True)\n\nall_videos = []\nfor parent in VIDEO_FOLDERS:\n if not os.path.exists(parent):\n print(f'[SKIP] 폴더 없음: {parent}')\n continue\n for sub in sorted(os.listdir(parent)):\n sub_path = os.path.join(parent, sub)\n if not os.path.isdir(sub_path) or sub not in INCLUDE_SUBFOLDERS:\n continue\n files = sorted([\n os.path.join(sub_path, f)\n for f in os.listdir(sub_path)\n if f.lower().endswith(('.mp4', '.mov', '.avi', '.mkv'))\n ])\n picked = files[:MAX_FILES_PER_CLASS]\n all_videos.extend(picked)\n print(f' {os.path.basename(parent)}/{sub}: {len(picked)}개 (전체 {len(files)}개 중)')\n\nprint(f'\\n합계: {len(all_videos)}개 영상')\nprint(f'gate model exists: {os.path.exists(GATE_MODEL_PATH)}')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-copy", + "metadata": {}, + "source": [ + "## 2. Drive → 로컬 복사" + ] + }, + { + "cell_type": "code", + "id": "cell-copy", + "metadata": {}, + "source": "from tqdm.notebook import tqdm as tqdm_nb\nimport time\n\nLOCAL_VIDEO_DIR = '/content/videos'\nos.makedirs(LOCAL_VIDEO_DIR, exist_ok=True)\n\ndef copy_with_retry(src, dst, retries=3, wait=5):\n for attempt in range(retries):\n try:\n shutil.copy2(src, dst)\n return True\n except OSError as e:\n print(f'\\n[WARN] 복사 실패 ({attempt+1}/{retries}): {os.path.basename(src)} — {e}')\n if attempt < retries - 1:\n print(f' {wait}초 후 재시도...')\n time.sleep(wait)\n # Drive 재마운트 시도\n try:\n from google.colab import drive\n drive.mount('/content/drive', force_remount=True)\n except Exception:\n pass\n print(f'[SKIP] 복사 포기: {os.path.basename(src)}')\n return False\n\nlocal_videos = []\nskipped = []\n\nfor src in tqdm_nb(all_videos, desc='Drive → 로컬'):\n rel = os.path.relpath(src, DRIVE_ROOT)\n dst = os.path.join(LOCAL_VIDEO_DIR, rel.replace(os.sep, '_'))\n if not os.path.exists(dst):\n ok = copy_with_retry(src, dst)\n if not ok:\n skipped.append(src)\n continue\n local_videos.append(dst)\n\nprint(f'\\n복사 완료: {len(local_videos)}개')\nif skipped:\n print(f'스킵된 파일 {len(skipped)}개:')\n for s in skipped:\n print(f' {os.path.basename(s)}')", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-models", + "metadata": {}, + "source": [ + "## 3. 모델 로드" + ] + }, + { + "cell_type": "code", + "id": "cell-models", + "metadata": {}, + "source": [ + "from ultralytics import YOLO\n", + "\n", + "_person_model = YOLO('yolo11n.pt')\n", + "print('Person YOLO: 완료')\n", + "\n", + "_gate_model = None\n", + "if os.path.exists(GATE_MODEL_PATH):\n", + " _gate_model = YOLO(GATE_MODEL_PATH)\n", + " print(f'Gate model: 완료 ({GATE_MODEL_PATH})')\n", + "else:\n", + " print('[WARN] gate_best.pt 없음 → gate 위치 필터 비활성화, CONFIG_DICT fallback 사용')\n", + "\n", + "_face_model = None\n", + "if os.path.exists(FACE_MODEL_PATH):\n", + " _face_model = YOLO(FACE_MODEL_PATH)\n", + " print('Face model: 완료')\n", + "else:\n", + " try:\n", + " from huggingface_hub import hf_hub_download\n", + " face_pt = hf_hub_download('arnabdhar/YOLOv8-Face-Detection', 'model.pt', local_dir='/content')\n", + " _face_model = YOLO(face_pt)\n", + " print('Face model: HuggingFace에서 로드')\n", + " except Exception as e:\n", + " print(f'Face model 로드 실패 → 블러 생략: {e}')" + ], + "outputs": [], + "execution_count": null + }, + { + "id": "cell-efficientnet", + "cell_type": "code", + "source": [ + "import torch\nimport torch.nn as nn\nfrom torchvision import models, transforms\nfrom pathlib import Path\n\n# ── EfficientNet 설정 ─────────────────────────────────────────────────────\nEFF_CLASSES = (\"jump\", \"crawling\", \"tailgating\", \"unpaid\", \"normal\")\nEFF_NUM_FRAMES = 16\nEFF_CONF_THRESHOLD = 0.50 # normal 판정 최소 신뢰도 (이 이상이면 FP로 제거)\n\nclass _EfficientNetClipClassifier(nn.Module):\n def __init__(self, num_classes=5):\n super().__init__()\n backbone = models.efficientnet_b0(weights=None)\n self.features = backbone.features\n self.avgpool = backbone.avgpool\n self.dropout = nn.Dropout(p=0.3)\n self.fc = nn.Linear(1280, num_classes)\n def forward(self, clips):\n b, t = clips.shape[:2]\n x = clips.view(b * t, *clips.shape[2:])\n x = self.features(x)\n x = self.avgpool(x).flatten(1)\n x = x.view(b, t, 1280).mean(dim=1)\n x = self.dropout(x)\n return self.fc(x)\n\n_eff_device = torch.device(\"cuda\") if torch.cuda.is_available() else torch.device(\"cpu\")\n_eff_model = None\n\nif os.path.exists(EFFICIENTNET_PATH):\n _eff_model = _EfficientNetClipClassifier(num_classes=len(EFF_CLASSES)).to(_eff_device)\n ckpt = torch.load(EFFICIENTNET_PATH, map_location=_eff_device)\n _eff_model.load_state_dict(ckpt[\"model_state\"])\n _eff_model.eval()\n print(f\"EfficientNet 로드 완료: {EFFICIENTNET_PATH}\")\nelse:\n print(f\"[WARN] EfficientNet 없음 → 2차 검증 비활성화: {EFFICIENTNET_PATH}\")\n\n_eff_transform = transforms.Compose([\n transforms.ToPILImage(),\n transforms.Resize(256),\n transforms.CenterCrop(224),\n transforms.ToTensor(),\n transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),\n])\n\ndef eff_verify(clip_path, threshold=EFF_CONF_THRESHOLD):\n \"\"\"클립 경로 → EfficientNet 분류 결과 dict (모델 없거나 clip_path=None이면 None).\"\"\"\n if _eff_model is None or clip_path is None:\n return None\n cap = cv2.VideoCapture(clip_path)\n total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))\n if total <= 0:\n cap.release(); return None\n idxs = np.linspace(0, max(total - 1, 0), EFF_NUM_FRAMES).astype(int)\n frames = []\n for idx in idxs:\n cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))\n ok, f = cap.read()\n if ok and f is not None:\n frames.append(cv2.cvtColor(f, cv2.COLOR_BGR2RGB))\n cap.release()\n if not frames: return None\n while len(frames) < EFF_NUM_FRAMES:\n frames.append(frames[-1])\n tensors = [_eff_transform(f) for f in frames[:EFF_NUM_FRAMES]]\n clip = torch.stack(tensors, dim=0).unsqueeze(0).to(_eff_device)\n with torch.no_grad():\n probs = torch.softmax(_eff_model(clip), dim=1).squeeze(0).cpu().tolist()\n pred_idx = int(max(range(len(EFF_CLASSES)), key=lambda i: probs[i]))\n confidence = probs[pred_idx]\n return {\n \"prediction\": EFF_CLASSES[pred_idx] if confidence >= threshold else \"unknown\",\n \"confidence\": round(confidence, 4),\n \"probs\": {c: round(probs[i], 4) for i, c in enumerate(EFF_CLASSES)},\n }\n\nprint(f\"EfficientNet 2차 분류기 준비 완료 threshold={EFF_CONF_THRESHOLD} device={_eff_device}\")" + ], + "metadata": {}, + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-modules", + "metadata": {}, + "source": [ + "## 4. 모듈 정의\n", + "### 4-A. YOLODetector / GateDetector / blur_faces" + ] + }, + { + "cell_type": "code", + "id": "cell-detectors", + "metadata": {}, + "source": [ + "import numpy as np\n", + "import cv2\n", + "from dataclasses import dataclass\n", + "from typing import List, Optional, Dict, Any\n", + "\n", + "@dataclass\n", + "class Detection:\n", + " x1: float; y1: float; x2: float; y2: float; confidence: float\n", + " @property\n", + " def cx(self): return (self.x1 + self.x2) / 2\n", + " @property\n", + " def cy(self): return (self.y1 + self.y2) / 2\n", + " @property\n", + " def width(self): return self.x2 - self.x1\n", + " @property\n", + " def height(self): return self.y2 - self.y1\n", + " @property\n", + " def aspect_ratio(self): return self.height / (self.width + 1e-6)\n", + "\n", + "class YOLODetector:\n", + " def __init__(self, conf_threshold=0.4):\n", + " self.model = _person_model\n", + " self.conf_threshold = conf_threshold\n", + " def detect_persons(self, frame):\n", + " results = self.model(frame, classes=[0], conf=self.conf_threshold, verbose=False)\n", + " out = []\n", + " for r in results:\n", + " if r.boxes is None: continue\n", + " for box in r.boxes:\n", + " x1, y1, x2, y2 = box.xyxy[0].tolist()\n", + " out.append(Detection(x1=x1, y1=y1, x2=x2, y2=y2, confidence=float(box.conf[0])))\n", + " return out\n", + "\n", + "class GateDetector:\n", + " def __init__(self, conf_thres=0.25):\n", + " self.model = _gate_model\n", + " self.conf_thres = conf_thres\n", + " @property\n", + " def enabled(self): return self.model is not None\n", + " def detect_best(self, frame):\n", + " if frame is None or self.model is None: return None\n", + " h, w = frame.shape[:2]\n", + " results = self.model.predict(source=frame, conf=self.conf_thres, verbose=False)\n", + " dets = []\n", + " for result in results:\n", + " if result.boxes is None: continue\n", + " for box in result.boxes:\n", + " x1, y1, x2, y2 = box.xyxy[0].tolist()\n", + " dets.append({\n", + " 'x1': max(0, min(int(x1), w-1)), 'y1': max(0, min(int(y1), h-1)),\n", + " 'x2': max(0, min(int(x2), w-1)), 'y2': max(0, min(int(y2), h-1)),\n", + " 'conf': round(float(box.conf[0]), 4)\n", + " })\n", + " return max(dets, key=lambda d: d['conf']) if dets else None\n", + "\n", + "def blur_faces(frame):\n", + " if _face_model is None: return frame\n", + " out = frame.copy()\n", + " for r in _face_model(frame, verbose=False):\n", + " if r.boxes is None: continue\n", + " for box in r.boxes:\n", + " x1, y1, x2, y2 = map(int, box.xyxy[0].tolist())\n", + " roi = out[y1:y2, x1:x2]\n", + " if roi.size: out[y1:y2, x1:x2] = cv2.GaussianBlur(roi, (51, 51), 0)\n", + " return out\n", + "\n", + "print('YOLODetector / GateDetector 완료')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-jump", + "metadata": {}, + "source": [ + "### 4-B. JumpDetector (Y축 변위 기반, 기존 검증값 사용)" + ] + }, + { + "cell_type": "code", + "id": "cell-jump-detector", + "metadata": {}, + "source": "from collections import deque\n\nclass JumpDetector:\n \"\"\"\n y2(발 끝) 변위 기반 점프 감지.\n - dynamic_multiplier : threshold = 정상 y2 변위 평균 × multiplier\n - min_score : score(y2 변위/키) 최소값\n - _gate_min : threshold 하한. gate 탐지 시 gate_height × 0.08로 자동 갱신\n \"\"\"\n def __init__(self, window_frames=30, jump_ratio=0.30,\n calibration_frames=90, dynamic_multiplier=5.0,\n min_score=0.25):\n self.window_frames = window_frames\n self.jump_ratio = jump_ratio\n self.calibration_frames = calibration_frames\n self.dynamic_multiplier = dynamic_multiplier\n self.min_score = min_score\n self._history = {}\n self._frame_count = 0\n self._calib_disps = []\n self._dynamic_threshold = None\n self._gate_min = 30.0 # gate 미탐지 시 기본 하한\n\n def set_gate_height(self, gate_h):\n \"\"\"gate 탐지 후 gate_height 기반으로 threshold 하한 재계산.\"\"\"\n self._gate_min = max(gate_h * 0.08, 15.0)\n if self._dynamic_threshold is not None:\n self._dynamic_threshold = max(self._dynamic_threshold, self._gate_min)\n\n def _upward_disp(self, tid):\n h = self._history.get(tid)\n if not h or len(h) < 5: return 0.0, 0.0\n y2_vals = [y2 for y2, _ in h]\n avg_h = float(np.mean([ht for _, ht in h]))\n n = len(y2_vals)\n base = float(np.mean(y2_vals[:max(1, n // 4)]))\n return base - min(y2_vals), avg_h\n\n def _try_calibrate(self):\n if not self._calib_disps: return\n mean_disp = float(np.mean(self._calib_disps))\n self._dynamic_threshold = max(mean_disp * self.dynamic_multiplier, self._gate_min)\n print(f'[JumpDetector] 보정 완료: 정상 y2 변위={mean_disp:.1f}px '\n f'→ threshold={self._dynamic_threshold:.1f}px '\n f'(×{self.dynamic_multiplier}, min={self._gate_min:.1f}px)')\n\n def update(self, tid, xyxy):\n x1, y1, x2, y2 = xyxy\n h = y2 - y1\n if tid not in self._history:\n self._history[tid] = deque(maxlen=self.window_frames)\n self._history[tid].append((y2, h))\n if self._frame_count < self.calibration_frames:\n disp, _ = self._upward_disp(tid)\n if disp > 0: self._calib_disps.append(disp)\n elif self._dynamic_threshold is None:\n self._try_calibrate()\n self._frame_count += 1\n\n def is_jump(self, tid):\n upward, avg_h = self._upward_disp(tid)\n if avg_h == 0: return False\n score = upward / avg_h\n if score < self.min_score: return False\n if self._dynamic_threshold is not None:\n return upward > self._dynamic_threshold\n return score > self.jump_ratio\n\n def get_score(self, tid):\n upward, avg_h = self._upward_disp(tid)\n return upward / avg_h if avg_h > 0 else 0.0\n\n def transfer_history(self, old_tid, new_tid):\n \"\"\"ID switch 보정: old_tid의 히스토리를 new_tid로 이어받기.\"\"\"\n if old_tid in self._history:\n self._history[new_tid] = self._history.pop(old_tid)\n\n def cleanup(self, active_tids):\n for tid in list(self._history.keys()):\n if tid not in active_tids:\n del self._history[tid]\n\nprint('JumpDetector 완료 (gate_height 기반 threshold, set_gate_height() 지원)')", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-gate-rel", + "metadata": {}, + "source": [ + "### 4-C. GateRelation (gate 기준 상대 좌표, 위치 필터용)" + ] + }, + { + "cell_type": "code", + "id": "cell-gate-rel", + "metadata": {}, + "source": [ + "from dataclasses import dataclass as dc\n", + "\n", + "@dc\n", + "class GateRelation:\n", + " in_gate: bool\n", + " rel_x: float\n", + " rel_y: float\n", + " vertical_zone: str # 'top' | 'middle' | 'bottom'\n", + " overlap_iou: float\n", + "\n", + "def compute_gate_relation(person, gate):\n", + " if gate is None: return None\n", + " gx1, gy1, gx2, gy2 = gate['x1'], gate['y1'], gate['x2'], gate['y2']\n", + " gw = gx2 - gx1; gh = gy2 - gy1\n", + " if gw <= 0 or gh <= 0: return None\n", + " pcx, pcy = person.cx, person.cy\n", + " in_gate = (gx1 <= pcx <= gx2) and (gy1 <= pcy <= gy2)\n", + " rel_x = (pcx - gx1) / gw\n", + " rel_y = (pcy - gy1) / gh\n", + " zone = 'top' if rel_y < 0.33 else ('middle' if rel_y < 0.66 else 'bottom')\n", + " ix1 = max(person.x1, gx1); iy1 = max(person.y1, gy1)\n", + " ix2 = min(person.x2, gx2); iy2 = min(person.y2, gy2)\n", + " inter = max(0, ix2-ix1) * max(0, iy2-iy1)\n", + " union = (person.x2-person.x1)*(person.y2-person.y1) + gw*gh - inter\n", + " iou = inter / union if union > 0 else 0.0\n", + " return GateRelation(in_gate=in_gate, rel_x=rel_x, rel_y=rel_y,\n", + " vertical_zone=zone, overlap_iou=iou)\n", + "\n", + "def near_gate_x(person, gate, margin_ratio=0.2):\n", + " \"\"\"person cx가 gate x-range ± margin 안에 있는지. gate 없으면 True.\"\"\"\n", + " if gate is None: return True\n", + " gx1, gx2 = gate['x1'], gate['x2']\n", + " margin = (gx2 - gx1) * margin_ratio\n", + " return (gx1 - margin) <= person.cx <= (gx2 + margin)\n", + "\n", + "print('GateRelation 완료')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-rules", + "metadata": {}, + "source": [ + "### 4-D. TrackedPerson / EventCandidate / Rules" + ] + }, + { + "cell_type": "code", + "id": "cell-rules", + "metadata": {}, + "source": [ + "from dataclasses import dataclass, field\nfrom collections import defaultdict\nfrom typing import Dict, List, Optional, Tuple\n\n@dataclass\nclass Zone:\n x1:int; y1:int; x2:int; y2:int\n def contains_point(self, x, y): return self.x1<=x<=self.x2 and self.y1<=y<=self.y2\n\n@dataclass\nclass PassLine:\n x1:int; y1:int; x2:int; y2:int\n\n@dataclass\nclass GateZoneConfig:\n camera_id:str; frame_width:int; frame_height:int\n gate_zone:Zone; pass_line:PassLine; jump_zone:Zone; crawl_zone:Zone\n tailgate_time_window_s:float = 3.0\n tailgate_distance_thresh:float = 200.0\n unpaid_hold_s:float = 5.0\n jump_min_frames:int = 3\n crawl_min_frames:int = 10 # v6: recall 회복\n crawl_aspect_ratio_thresh:float= 1.6 # v6: recall 회복\n tailgating_min_frames:int = 5 # v6: FP 억제\n gate_overlap_thresh:float = 0.25 # v6: 더 엄격\n tailgate_max_dist:float = 200.0 # v6 NEW: 두 사람 거리 상한\n\n @classmethod\n def from_dict(cls, d):\n return cls(\n camera_id=d['camera_id'], frame_width=d['frame_width'],\n frame_height=d['frame_height'], gate_zone=Zone(**d['gate_zone']),\n pass_line=PassLine(**d['pass_line']), jump_zone=Zone(**d['jump_zone']),\n crawl_zone=Zone(**d['crawl_zone']),\n tailgate_time_window_s =d.get('tailgate_time_window_s', 3.0),\n tailgate_distance_thresh =d.get('tailgate_distance_thresh', 200.0),\n unpaid_hold_s =d.get('unpaid_hold_s', 5.0),\n jump_min_frames =d.get('jump_min_frames', 3),\n crawl_min_frames =d.get('crawl_min_frames', 10),\n crawl_aspect_ratio_thresh=d.get('crawl_aspect_ratio_thresh', 1.6),\n tailgating_min_frames =d.get('tailgating_min_frames', 5),\n gate_overlap_thresh =d.get('gate_overlap_thresh', 0.25),\n tailgate_max_dist =d.get('tailgate_max_dist', 200.0),\n )\n\n@dataclass\nclass TrackedPerson:\n track_id:int; x1:float; y1:float; x2:float; y2:float\n @property\n def cx(self): return (self.x1+self.x2)/2\n @property\n def cy(self): return (self.y1+self.y2)/2\n @property\n def aspect_ratio(self): return (self.y2-self.y1)/((self.x2-self.x1)+1e-6)\n\n@dataclass\nclass EventCandidate:\n event_type:str; start_frame:int; end_frame:int; track_ids:List[int]\n confidence:float; reason:str\n status:str='candidate'\n efficientnet_result:Optional[dict]=None\n\ndef _bbox_overlap_ratio(px1, py1, px2, py2, gx1, gy1, gx2, gy2) -> float:\n ix1 = max(px1, gx1); iy1 = max(py1, gy1)\n ix2 = min(px2, gx2); iy2 = min(py2, gy2)\n if ix2 <= ix1 or iy2 <= iy1: return 0.0\n inter = (ix2 - ix1) * (iy2 - iy1)\n p_area = max((px2 - px1) * (py2 - py1), 1.0)\n return inter / p_area\n\nclass IDRemapper:\n def __init__(self, dist_thresh=80, frame_thresh=15):\n self.dist_thresh = dist_thresh\n self.frame_thresh = frame_thresh\n self._lost: dict = {}\n self._remap: dict = {}\n def canonical(self, tid): return self._remap.get(tid, tid)\n def update(self, fi, persons, prev_tids):\n current_tids = {p.track_id for p in persons}\n for tid in prev_tids - current_tids:\n canonical = self._remap.get(tid, tid)\n if canonical not in self._lost: self._lost[canonical] = (None, None, fi)\n expired = [tid for tid,(_, _, f) in self._lost.items() if fi-f > self.frame_thresh]\n for tid in expired: del self._lost[tid]\n new_mappings = {}; known_canonicals = set(self._remap.values())\n for p in persons:\n if p.track_id in self._remap or p.track_id in known_canonicals: continue\n best_match, best_dist = None, self.dist_thresh\n for old_tid, (cx, cy, _) in self._lost.items():\n if cx is None: continue\n dist = ((p.cx-cx)**2 + (p.cy-cy)**2)**0.5\n if dist < best_dist: best_dist=dist; best_match=old_tid\n if best_match is not None:\n self._remap[p.track_id]=best_match; new_mappings[p.track_id]=best_match\n del self._lost[best_match]\n return new_mappings\n def record_positions(self, persons):\n for p in persons:\n canonical = self._remap.get(p.track_id, p.track_id)\n if canonical in self._lost:\n cx, cy, f = self._lost[canonical]\n self._lost[canonical] = (p.cx, p.cy, f)\n\n# ─── CrawlingRule v6: AR=1.6, min_frames=10 ─────────────────\nclass CrawlingRule:\n def __init__(self, cfg):\n self.cfg=cfg; self._ar_buf=defaultdict(lambda: deque(maxlen=60))\n self._low_frames=defaultdict(int); self._triggered=set()\n self.debug=False; self._max_low=defaultdict(int) # Fix4 diag\n\n def update(self, fi, persons, cached_gate=None):\n mf=self.cfg.crawl_min_frames; th=self.cfg.crawl_aspect_ratio_thresh\n out=[]; active={p.track_id for p in persons}\n for p in persons:\n if not near_gate_x(p, cached_gate, margin_ratio=0.3):\n if self.debug and fi%30==0:\n print(f'[CRAWL] f={fi} ID={p.track_id} SKIP near_gate_x=False')\n self._low_frames[p.track_id]=0; continue\n self._ar_buf[p.track_id].append(p.aspect_ratio)\n median_ar=float(np.median(list(self._ar_buf[p.track_id])))\n if median_ar < th:\n self._low_frames[p.track_id]+=1\n self._max_low[p.track_id]=max(self._max_low[p.track_id], self._low_frames[p.track_id])\n if self.debug and self._low_frames[p.track_id]%5==0:\n print(f'[CRAWL] f={fi} ID={p.track_id} AR={median_ar:.2f}<{th} cnt={self._low_frames[p.track_id]}/{mf}')\n else:\n cnt=self._low_frames[p.track_id]\n if cnt>=mf and p.track_id not in self._triggered:\n self._triggered.add(p.track_id)\n if self.debug:\n print(f'[CRAWL ✓] f={fi} ID={p.track_id} TRIGGERED AR={median_ar:.2f} cnt={cnt}')\n out.append(EventCandidate('crawling',fi-cnt,fi,[p.track_id],\n min(1.0,cnt/(mf*3)),f'AR={median_ar:.2f}<{th} {cnt}프레임'))\n self._low_frames[p.track_id]=0\n for tid in list(self._low_frames):\n if tid not in active:\n cnt=self._low_frames[tid]\n if cnt>=mf and tid not in self._triggered:\n self._triggered.add(tid)\n out.append(EventCandidate('crawling',fi-cnt,fi,[tid],\n min(1.0,cnt/(mf*3)),f'AR<{th} {cnt}프레임 후 시야이탈'))\n del self._low_frames[tid]\n if tid in self._ar_buf: del self._ar_buf[tid]\n return out\n\n# ─── TailgatingRule v6 ───────────────────────────────────────\n# [v5 대비 변경]\n# 1) min_cooccupy_frames : 2 → 5 (FP 억제)\n# 2) gate_overlap_thresh : 0.15 → 0.25 (더 엄격)\n# 3) max_dist 필터 추가 : 두 사람 거리 > 200px 이면 pair 무시\n# → Normal 혼잡 영상에서 멀리 있는 사람 쌍(dist=400px 등) FP 제거\nclass TailgatingRule:\n def __init__(self, cfg):\n self.cfg=cfg\n self.min_cooccupy_frames=cfg.tailgating_min_frames # 5\n self.gate_overlap_thresh=cfg.gate_overlap_thresh # 0.25\n self.max_dist =cfg.tailgate_max_dist # 200px\n self._pair_streak: dict ={}; self._triggered: set=set()\n self._cy_history: dict =defaultdict(lambda: deque(maxlen=30))\n self.debug=False\n\n def _movement_dir(self, tid) -> float:\n h=list(self._cy_history[tid])\n if len(h)<3: return 0.0\n return h[-1]-h[0]\n\n def _in_gate(self, p, gx1, gy1, gx2, gy2):\n ratio=_bbox_overlap_ratio(p.x1,p.y1,p.x2,p.y2,gx1,gy1,gx2,gy2)\n return ratio>=self.gate_overlap_thresh, ratio\n\n def update(self, fi, persons, fps, cached_gate=None):\n out=[]\n if cached_gate:\n gx1,gy1=cached_gate['x1'],cached_gate['y1']\n gx2,gy2=cached_gate['x2'],cached_gate['y2']\n else:\n gz=self.cfg.gate_zone; gx1,gy1,gx2,gy2=gz.x1,gz.y1,gz.x2,gz.y2\n\n in_gate_persons=[]; overlap_map={}\n for p in persons:\n inside,ratio=self._in_gate(p,gx1,gy1,gx2,gy2)\n overlap_map[p.track_id]=ratio\n if inside:\n in_gate_persons.append(p)\n self._cy_history[p.track_id].append(p.cy)\n\n if self.debug and fi%15==0:\n print(f'[TAIL DEBUG] frame={fi:5d}, persons={len(persons)}, '\n f'in_gate={len(in_gate_persons)}, '\n f'max_streak={max(self._pair_streak.values(),default=0)}')\n for p in persons:\n tag='IN' if p in in_gate_persons else '--'\n print(f' [{tag}] id={p.track_id:3d} overlap={overlap_map[p.track_id]:.3f}')\n\n current_pairs=set()\n if len(in_gate_persons)>=2:\n for i in range(len(in_gate_persons)):\n for j in range(i+1,len(in_gate_persons)):\n pa_c=in_gate_persons[i]; pb_c=in_gate_persons[j]\n dist_ij=((pa_c.cx-pb_c.cx)**2+(pa_c.cy-pb_c.cy)**2)**0.5\n if dist_ij>self.max_dist: # ★ max_dist 필터\n if self.debug:\n print(f'[TAIL DEBUG] dist 필터: '\n f'ID{pa_c.track_id}-ID{pb_c.track_id} '\n f'{dist_ij:.0f}px>{self.max_dist}px → skip')\n continue\n current_pairs.add(frozenset({pa_c.track_id,pb_c.track_id}))\n\n for key in list(self._pair_streak.keys()):\n if key not in current_pairs: del self._pair_streak[key]\n\n for key in current_pairs:\n self._pair_streak[key]=self._pair_streak.get(key,0)+1\n streak=self._pair_streak[key]\n if streak>=self.min_cooccupy_frames and key not in self._triggered:\n ta,tb=list(key)\n pa=next((p for p in in_gate_persons if p.track_id==ta),in_gate_persons[0])\n pb=next((p for p in in_gate_persons if p.track_id==tb),in_gate_persons[1])\n dir_a=self._movement_dir(ta); dir_b=self._movement_dir(tb)\n if dir_a!=0.0 and dir_b!=0.0 and dir_a*dir_b<0:\n if self.debug:\n print(f'[TAIL DEBUG] 교행: ID{ta}(dir={dir_a:.1f}) '\n f'vs ID{tb}(dir={dir_b:.1f}) → skip')\n continue\n self._triggered.add(key)\n dist=((pa.cx-pb.cx)**2+(pa.cy-pb.cy)**2)**0.5\n dir_label='↓' if dir_a>=0 else '↑'\n ovlp_a=overlap_map.get(ta,0.0); ovlp_b=overlap_map.get(tb,0.0)\n out.append(EventCandidate(\n 'tailgating',fi-streak+1,fi,[ta,tb],0.75,\n f'gate 동시 점유 {streak}f(min={self.min_cooccupy_frames}), '\n f'방향({dir_label}), dist={dist:.0f}px, '\n f'overlap=({ovlp_a:.2f}/{ovlp_b:.2f})'))\n return out\n\nprint('Rules 완료 (v6)')\nprint(' GateZoneConfig : tailgate_max_dist 필드 추가')\nprint(' CrawlingRule : AR=1.6 / min_frames=10 (recall 회복)')\nprint(' TailgatingRule : min=5 / overlap>=0.25 / max_dist=200px 필터')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-config", + "metadata": {}, + "source": [ + "## 5. Gate 설정\n", + "\n", + "CONFIG_DICT 좌표는 **gate 미탐지 시 fallback**으로만 사용됩니다. \n", + "gate bbox가 탐지되면 `pass_line` / `crawl_zone` / `gate_zone`이 자동으로 gate 좌표에 맞게 보정됩니다." + ] + }, + { + "cell_type": "code", + "id": "cell-config", + "metadata": {}, + "source": "# ═══════════════════════════════════════════════════════════════\n# 설정값 — 이 블록만 수정해서 파라미터를 바꿀 수 있습니다\n# ═══════════════════════════════════════════════════════════════\nCONFIG_DICT = {\n 'camera_id': 'cam0', 'frame_width': 640, 'frame_height': 480,\n 'gate_zone': {'x1': 180, 'y1': 80, 'x2': 460, 'y2': 420},\n 'pass_line': {'x1': 180, 'y1': 280, 'x2': 460, 'y2': 280},\n 'jump_zone': {'x1': 160, 'y1': 40, 'x2': 480, 'y2': 180},\n 'crawl_zone': {'x1': 160, 'y1': 340, 'x2': 480, 'y2': 430},\n\n # ── Jump ───────────────────────────────────────────────────\n 'jump_min_frames': 3,\n\n # ── Crawling ───────────────────────────────────────────────\n 'crawl_min_frames': 10, # v5 15 → 10 (recall 회복)\n 'crawl_aspect_ratio_thresh': 1.6, # v5 1.55 → 1.6 (recall 회복)\n\n # ── Tailgating ─────────────────────────────────────────────\n 'tailgate_time_window_s': 3.0,\n 'tailgate_distance_thresh': 150.0,\n 'tailgating_min_frames': 5, # v5 2 → 5 (Normal FP 억제)\n 'gate_overlap_thresh': 0.25, # v5 0.15 → 0.25 (더 엄격)\n 'tailgate_max_dist': 200, # NEW: 두 사람 거리(px) 초과 시 pair 무시\n}\n\ncfg = GateZoneConfig.from_dict(CONFIG_DICT)\nprint('Gate config 완료 (v6)')\nprint(f' crawl_min_frames = {cfg.crawl_min_frames}')\nprint(f' crawl_aspect_ratio_thresh = {cfg.crawl_aspect_ratio_thresh}')\nprint(f' tailgating_min_frames = {cfg.tailgating_min_frames}')\nprint(f' gate_overlap_thresh = {cfg.gate_overlap_thresh}')\nprint(f' tailgate_max_dist = {cfg.tailgate_max_dist}')", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "cell-preview", + "metadata": {}, + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "PREVIEW_INDEX = 0\n", + "cap = cv2.VideoCapture(local_videos[PREVIEW_INDEX])\n", + "ret, frame = cap.read()\n", + "cap.release()\n", + "\n", + "if ret:\n", + " vis = frame.copy()\n", + " pl = cfg.pass_line\n", + " cv2.line(vis, (pl.x1, pl.y1), (pl.x2, pl.y2), (0, 255, 0), 2)\n", + " cv2.putText(vis, 'PASS_LINE(fallback)', (pl.x1, pl.y1-5),\n", + " cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0,255,0), 1)\n", + " for z, c, lbl in [\n", + " (cfg.crawl_zone, (220, 0, 0), 'CRAWL(fallback)'),\n", + " (cfg.gate_zone, (180, 180, 0), 'GATE_CFG(fallback)'),\n", + " ]:\n", + " cv2.rectangle(vis, (z.x1, z.y1), (z.x2, z.y2), c, 2)\n", + " cv2.putText(vis, lbl, (z.x1, z.y1-4), cv2.FONT_HERSHEY_SIMPLEX, 0.4, c, 1)\n", + "\n", + " gate_det_preview = GateDetector(conf_thres=0.25)\n", + " gate_bbox = gate_det_preview.detect_best(frame)\n", + " if gate_bbox:\n", + " gx1, gy1, gx2, gy2 = gate_bbox['x1'], gate_bbox['y1'], gate_bbox['x2'], gate_bbox['y2']\n", + " gh = gy2 - gy1\n", + " gate_cy = (gy1 + gy2) // 2\n", + " # gate bbox\n", + " cv2.rectangle(vis, (gx1, gy1), (gx2, gy2), (0, 220, 220), 3)\n", + " cv2.putText(vis, f\"GATE_DETECT {gate_bbox['conf']:.2f}\", (gx1, gy1-18),\n", + " cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 220, 220), 2)\n", + " # 자동 보정된 pass_line\n", + " cv2.line(vis, (gx1, gate_cy), (gx2, gate_cy), (0, 255, 180), 2)\n", + " cv2.putText(vis, 'PASS_LINE(auto)', (gx1, gate_cy-5),\n", + " cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0,255,180), 1)\n", + " # 자동 보정된 crawl_zone\n", + " cz_y1 = int(gy1 + gh * 0.6)\n", + " cv2.rectangle(vis, (gx1, cz_y1), (gx2, gy2), (255, 100, 0), 2)\n", + " cv2.putText(vis, 'CRAWL(auto)', (gx1, cz_y1+12), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255,100,0), 1)\n", + " print(f'Gate 탐지: {gate_bbox}')\n", + " print(f' → pass_line y={gate_cy}, crawl_zone y1={cz_y1}')\n", + " else:\n", + " print('Gate 탐지 안 됨 → CONFIG_DICT fallback 사용')\n", + "\n", + " plt.figure(figsize=(10, 6))\n", + " plt.imshow(cv2.cvtColor(vis, cv2.COLOR_BGR2RGB))\n", + " plt.title(f'Zone 미리보기 — {os.path.basename(local_videos[PREVIEW_INDEX])}')\n", + " plt.axis('off'); plt.show()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "815e89d2", + "source": "### 5-B. Gate 검증 시각화 — 영상 샘플별 gate bbox 탐지 확인", + "metadata": {} + }, + { + "cell_type": "code", + "id": "beb0c656", + "source": "import matplotlib.pyplot as plt\n\n# 각 클래스에서 1개씩 샘플 추출 (최대 VERIFY_N개)\nVERIFY_N = 8\nVERIFY_FRAME = 30 # 각 영상의 몇 번째 프레임을 확인할지\n\nverify_videos = []\nseen_classes = set()\nfor vp in local_videos:\n fname = os.path.basename(vp).lower()\n cls = next((c for c in ['crawling', 'jump', 'tailgating', 'normal', 'nomal'] if c in fname), 'other')\n key = (cls, os.path.basename(os.path.dirname(vp)))\n if key not in seen_classes:\n seen_classes.add(key)\n verify_videos.append((vp, cls))\n if len(verify_videos) >= VERIFY_N:\n break\n\ngate_det_v = GateDetector(conf_thres=0.25)\ncols = min(4, len(verify_videos))\nrows = (len(verify_videos) + cols - 1) // cols\nfig, axes = plt.subplots(rows, cols, figsize=(cols * 4, rows * 3.5))\naxes = np.array(axes).flatten() if len(verify_videos) > 1 else [axes]\n\ndetected_count = 0\nfor ax, (vp, cls) in zip(axes, verify_videos):\n cap = cv2.VideoCapture(vp)\n cap.set(cv2.CAP_PROP_POS_FRAMES, VERIFY_FRAME)\n ret, frame = cap.read()\n cap.release()\n if not ret:\n ax.axis('off'); continue\n\n vis = frame.copy()\n gate = gate_det_v.detect_best(frame)\n if gate:\n detected_count += 1\n gx1, gy1, gx2, gy2 = gate['x1'], gate['y1'], gate['x2'], gate['y2']\n gh = gy2 - gy1\n cv2.rectangle(vis, (gx1, gy1), (gx2, gy2), (0, 220, 220), 3)\n cv2.putText(vis, f\"conf={gate['conf']:.2f}\", (gx1, gy1-8),\n cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 220, 220), 2)\n # pass_line\n gate_cy = (gy1 + gy2) // 2\n cv2.line(vis, (gx1, gate_cy), (gx2, gate_cy), (0, 255, 150), 2)\n # crawl_zone\n cz_y1 = int(gy1 + gh * 0.6)\n cv2.rectangle(vis, (gx1, cz_y1), (gx2, gy2), (255, 100, 0), 2)\n title_color = 'green'\n else:\n cv2.putText(vis, 'NO GATE', (10, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)\n title_color = 'red'\n\n ax.imshow(cv2.cvtColor(vis, cv2.COLOR_BGR2RGB))\n ax.set_title(f'[{cls}] {os.path.basename(vp)[:25]}', fontsize=8, color=title_color)\n ax.axis('off')\n\nfor ax in axes[len(verify_videos):]:\n ax.axis('off')\n\nplt.suptitle(f'Gate 탐지 검증: {detected_count}/{len(verify_videos)}개 성공', fontsize=12)\nplt.tight_layout()\nplt.show()\nprint(f'탐지율: {detected_count}/{len(verify_videos)} ({100*detected_count//len(verify_videos) if verify_videos else 0}%)')", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "md-pipeline", + "metadata": {}, + "source": [ + "## 6. 파이프라인 함수\n", + "\n", + "**gate bbox 탐지 시 자동 좌표 보정**\n", + "- `pass_line` → gate 수평 중심선 (gate_cy)\n", + "- `crawl_zone` → gate 하단 40% 영역\n", + "- `gate_zone` → gate bbox 전체 (tailgating 판단 영역)\n", + "\n", + "**jump 감지 구조**\n", + "1. 매 프레임 `JumpDetector.update()` → Y축 변위 누적\n", + "2. `JumpDetector.is_jump()` → True이면 jump 후보\n", + "3. gate 탐지된 경우: person cx가 gate x-range ±20% 안인지 확인\n", + "4. gate 없으면 필터 없이 통과" + ] + }, + { + "cell_type": "code", + "id": "cell-pipeline", + "metadata": {}, + "source": [ + "import supervision as sv\nfrom pathlib import Path\nfrom tqdm.notebook import tqdm as tqdm_nb\nimport copy\n\ndef _save_clip(c, buf, out_dir, fps, margin_s=2.0):\n m = int(fps * margin_s)\n frames = [f for fi, f in buf if c.start_frame-m <= fi <= c.end_frame+m]\n if not frames: return None\n name = f\"{c.event_type}_{c.start_frame}_{'_'.join(str(t) for t in c.track_ids)}.mp4\"\n path = str(Path(out_dir) / name)\n h, w = frames[0].shape[:2]\n writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*'mp4v'), fps, (w, h))\n for f in frames: writer.write(f)\n writer.release()\n return path\n\ndef _update_cfg_from_gate(cfg, gate):\n gx1, gy1, gx2, gy2 = gate['x1'], gate['y1'], gate['x2'], gate['y2']\n gh = gy2 - gy1\n cfg.pass_line = PassLine(gx1, (gy1+gy2)//2, gx2, (gy1+gy2)//2)\n cfg.crawl_zone = Zone(gx1, int(gy1 + gh * 0.6), gx2, gy2)\n cfg.gate_zone = Zone(gx1, gy1, gx2, gy2)\n\ndef _draw_overlay(frame, cfg, persons, gate_bbox=None, jump_scores=None, remapper=None):\n z = cfg.crawl_zone\n cv2.rectangle(frame, (z.x1, z.y1), (z.x2, z.y2), (180, 0, 0), 1)\n cv2.putText(frame, 'CRAWL', (z.x1+2, z.y1+12),\n cv2.FONT_HERSHEY_SIMPLEX, 0.38, (180,0,0), 1)\n if gate_bbox:\n gx1, gy1, gx2, gy2 = gate_bbox['x1'], gate_bbox['y1'], gate_bbox['x2'], gate_bbox['y2']\n cv2.rectangle(frame, (gx1, gy1), (gx2, gy2), (0, 220, 220), 2)\n cv2.putText(frame, f\"gate {gate_bbox['conf']:.2f}\", (gx1, gy1-6),\n cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 220, 220), 1, cv2.LINE_AA)\n else:\n cv2.putText(frame, 'gate: not found', (8, 48),\n cv2.FONT_HERSHEY_SIMPLEX, 0.5, (80, 80, 200), 1)\n for p in persons:\n score = (jump_scores or {}).get(p.track_id, 0.0)\n is_jumping = score >= 0.25\n color = (0, 0, 255) if is_jumping else (0, 200, 0)\n cv2.rectangle(frame, (int(p.x1), int(p.y1)), (int(p.x2), int(p.y2)), color, 2 if is_jumping else 1)\n canonical = remapper.canonical(p.track_id) if remapper else p.track_id\n id_label = f'{canonical}' if canonical != p.track_id else f'{p.track_id}'\n label = f'ID:{id_label} J:{score:.2f}' if score > 0.05 else f'ID:{id_label}'\n cv2.putText(frame, label, (int(p.x1), int(p.y1)-4),\n cv2.FONT_HERSHEY_SIMPLEX, 0.38, color, 1)\n\ndef run_pipeline(video_path, cfg_base, output_dir=OUTPUT_DIR, checkpoint=CHECKPOINT, conf=0.4,\n gate_conf=0.25, near_gate_margin=0.2,\n jump_multiplier=5.0, jump_min_score=0.25,\n id_remap_frame_thresh=15,\n verbose_rules=False, max_events=None):\n Path(output_dir+'/clips').mkdir(parents=True, exist_ok=True)\n cfg = copy.deepcopy(cfg_base)\n\n detector = YOLODetector(conf_threshold=conf)\n gate_det = GateDetector(conf_thres=gate_conf) # Fix2\n tracker = sv.ByteTrack()\n remapper = IDRemapper(dist_thresh=80, frame_thresh=id_remap_frame_thresh) # Fix2\n\n jump_detector = JumpDetector(window_frames=30, jump_ratio=0.30,\n calibration_frames=90,\n dynamic_multiplier=jump_multiplier, # Fix2\n min_score=jump_min_score) # Fix2\n jump_triggered = set()\n jump_consec = defaultdict(int) # 연속 jump 프레임 카운터 (FP 억제)\n\n rules = {\n 'crawling': CrawlingRule(cfg),\n 'tailgating': TailgatingRule(cfg),\n }\n rules['tailgating'].debug = verbose_rules # verbose_rules=True → [TAIL DEBUG] 로그 출력\n rules['crawling'].debug = verbose_rules # Fix4: [CRAWL] 탈락 사유 출력\n\n # ── Fix4: 진단 정보 수집 ──────────────────────────────────────\n diag_info = {\n 'gate_found_frames': 0,\n 'total_frames' : 0,\n 'unique_tids' : set(),\n 'id_switches' : 0,\n 'jump_peak_scores' : {},\n 'tail_max_streak' : 0,\n }\n cap = cv2.VideoCapture(video_path)\n total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))\n fps = cap.get(cv2.CAP_PROP_FPS) or 30.0\n buf = deque(maxlen=int(fps * 20))\n all_c = []; ann_frames = []; fi = 0\n cached_gate = None; gate_cache_age = 0\n gate_calibrated = False\n prev_tids: set = set()\n\n for _ in tqdm_nb(range(total or 9999), desc=os.path.basename(video_path), leave=False):\n ret, frame = cap.read()\n if not ret: break\n fi += 1; buf.append((fi, frame.copy()))\n\n if max_events is not None and len(all_c) >= max_events:\n print(f' [조기 종료] {max_events}건 달성 (frame={fi})')\n break\n\n clean = blur_faces(frame.copy())\n\n # person 탐지 + tracking\n raw = detector.detect_persons(clean)\n if raw:\n xyxy = np.array([[d.x1, d.y1, d.x2, d.y2] for d in raw], dtype=float)\n sv_d = sv.Detections(xyxy=xyxy, confidence=np.array([d.confidence for d in raw]))\n else:\n sv_d = sv.Detections.empty()\n sv_d = tracker.update_with_detections(sv_d)\n persons = [TrackedPerson(int(sv_d.tracker_id[i]), *[float(v) for v in sv_d.xyxy[i]])\n for i in range(len(sv_d))] if sv_d.tracker_id is not None else []\n\n # ID switch 보정\n remapper.record_positions(persons)\n new_maps = remapper.update(fi, persons, prev_tids)\n for new_tid, old_tid in new_maps.items():\n jump_detector.transfer_history(old_tid, new_tid)\n if old_tid in jump_triggered:\n jump_triggered.add(new_tid)\n prev_tids = {p.track_id for p in persons}\n\n # gate bbox 탐지 + 캐시 + 좌표 자동 보정\n if gate_det.enabled:\n new_gate = gate_det.detect_best(clean)\n if new_gate:\n cached_gate = new_gate; gate_cache_age = 0\n diag_info['gate_found_frames'] += 1 # Fix4\n _update_cfg_from_gate(cfg, cached_gate)\n gate_h = cached_gate['y2'] - cached_gate['y1']\n jump_detector.set_gate_height(gate_h)\n if not gate_calibrated:\n gate_calibrated = True\n print(f'[{os.path.basename(video_path)}] gate 보정 → '\n f'y={cfg.pass_line.y1}, jump_min={jump_detector._gate_min:.1f}px')\n else:\n gate_cache_age += 1\n if gate_cache_age > GATE_CACHE_FRAMES:\n cached_gate = None\n\n # JumpDetector 업데이트\n active_tids = set()\n if sv_d.tracker_id is not None:\n for i, tid in enumerate(sv_d.tracker_id):\n tid = int(tid)\n active_tids.add(tid)\n diag_info['unique_tids'].add(remapper.canonical(tid)) # Fix4\n jump_detector.update(tid, sv_d.xyxy[i])\n jump_detector.cleanup(active_tids)\n jump_scores = {p.track_id: jump_detector.get_score(p.track_id) for p in persons}\n # Fix4: jump peak + tail streak 추적\n for p in persons:\n canon = remapper.canonical(p.track_id)\n sc = jump_scores.get(p.track_id, 0.0)\n if sc > diag_info['jump_peak_scores'].get(canon, 0.0):\n diag_info['jump_peak_scores'][canon] = sc\n if rules['tailgating']._pair_streak:\n diag_info['tail_max_streak'] = max(\n diag_info['tail_max_streak'],\n max(rules['tailgating']._pair_streak.values()))\n\n if verbose_rules and fi % 30 == 0 and persons:\n for p in persons:\n canon = remapper.canonical(p.track_id)\n jsc = jump_scores.get(p.track_id, 0.0)\n near_g = near_gate_x(p, cached_gate, 0.3)\n consec = jump_consec.get(canon, 0)\n in_trig= canon in jump_triggered\n print(f' [VB] f={fi} ID={canon} ar={p.aspect_ratio:.2f} '\n f'near_gate={near_g} jump_sc={jsc:.2f} '\n f'consec={consec}/{cfg_base.jump_min_frames} triggered={in_trig}')\n\n cands = []\n\n # ── jump ────────────────────────────────────────────────\n # 3프레임 연속 is_jump()=True일 때만 트리거 (1프레임 FP 억제)\n for p in persons:\n canonical = remapper.canonical(p.track_id)\n if canonical in jump_triggered: continue\n if jump_detector.is_jump(p.track_id) and near_gate_x(p, cached_gate, margin_ratio=near_gate_margin):\n jump_consec[canonical] += 1\n else:\n jump_consec[canonical] = 0 # 조건 안 맞으면 리셋\n if jump_consec[canonical] < cfg_base.jump_min_frames: continue\n jump_triggered.add(canonical)\n score = jump_scores.get(p.track_id, 0.0)\n gate_info = f'gate_filter=ON conf={cached_gate[\"conf\"]:.2f}' if cached_gate else 'gate_filter=OFF'\n cands.append(EventCandidate('jump', fi, fi, [canonical],\n min(1.0, score/0.5),\n f'Y(y2) score={score:.2f} consec=3 ({gate_info})'))\n\n # ── crawling / tailgating ────────────────────────────────\n cands += rules['crawling'].update(fi, persons, cached_gate)\n cands += rules['tailgating'].update(fi, persons, fps, cached_gate)\n\n for c in cands:\n if max_events is not None and len(all_c) >= max_events:\n break\n clip_path = _save_clip(c, buf, output_dir+'/clips', fps)\n\n # ── EfficientNet 2차 검증 ──────────────────────────────────\n eff = eff_verify(clip_path)\n c.efficientnet_result = eff\n if eff and eff[\"prediction\"] == \"normal\":\n print(f' [EFF✗] {c.event_type} → normal({eff[\"confidence\"]:.2f}) 제거 ' +\n str(os.path.basename(clip_path or \"\")))\n continue # FP로 간주하고 기록 제외\n eff_tag = f' eff={eff[\"prediction\"]}({eff[\"confidence\"]:.2f})' if eff else \"\"\n # ──────────────────────────────────────────────────────────\n\n all_c.append(c)\n print(f' [{c.event_type}] frame={fi} tracks={c.track_ids} '\n f'conf={c.confidence:.2f}{eff_tag} | {c.reason}')\n vis = frame.copy()\n _draw_overlay(vis, cfg, persons, gate_bbox=cached_gate,\n jump_scores=jump_scores, remapper=remapper)\n cv2.putText(vis, f'!! {c.event_type.upper()}', (10, 30),\n cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)\n ann_frames.append((fi, c.event_type, vis.copy()))\n\n # ── Fix4: diag finalize ─────────────────────────────────────\n diag_info['total_frames'] = fi\n diag_info['id_switches'] = len(remapper._remap)\n if verbose_rules:\n gf = diag_info['gate_found_frames']\n print(f' [DIAG] gate={gf}/{fi}f | '\n f'tracks={len(diag_info[\"unique_tids\"])} | '\n f'id_sw={diag_info[\"id_switches\"]}')\n pk = max(diag_info['jump_peak_scores'].values()) if diag_info['jump_peak_scores'] else 0.0\n print(f' [DIAG] jump_peak={pk:.2f} | tail_max_streak={diag_info[\"tail_max_streak\"]}')\n cap.release()\n with open(str(Path(output_dir)/'events.json'), 'w', encoding='utf-8') as f:\n json.dump([{'event_type': c.event_type, 'start_frame': c.start_frame,\n 'end_frame': c.end_frame, 'track_ids': c.track_ids,\n 'confidence': round(c.confidence, 4), 'reason': c.reason,\n 'status': c.status} for c in all_c],\n f, indent=2, ensure_ascii=False)\n return all_c, ann_frames, diag_info\n\nprint('Pipeline 함수 완료 (v6)')\nprint(' IDRemapper 연결: ID switch 보정 + jump 히스토리 이어받기')\nprint(' set_gate_height: gate 탐지 시 jump threshold 하한 자동 갱신')\nprint(' 활성 이벤트: jump / crawling / tailgating')\nprint(' verbose_rules=True 시 [TAIL DEBUG] 로그 출력')\nprint(' tailgating: min=5 / overlap>=0.25 / max_dist=200px')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-run", + "metadata": {}, + "source": [ + "## 7. 전체 영상 파이프라인 실행" + ] + }, + { + "cell_type": "code", + "id": "c7590ada", + "source": [ + "# ─── Fix1: 정답 라벨 = 상위 폴더명 기준 (cell-run보다 먼저 정의) ────\ndef get_gt_from_path(vp):\n \"\"\"영상 경로에서 정답 라벨 추출. 상위 폴더명이 우선, fallback=파일명.\"\"\"\n folder = os.path.basename(os.path.dirname(vp)).lower()\n if folder in {\"jump\", \"crawling\", \"tailgating\"}:\n return folder\n if folder in {\"normal\", \"nomal\"}:\n return \"normal\"\n fname = os.path.basename(vp).lower()\n for cls in (\"jump\", \"crawling\", \"tailgating\"):\n if cls in fname:\n return cls\n return \"normal\"\n\nimport json, datetime\n\ndef save_run_summary(label, candidates, vmap, target_videos,\n settings=None, diag=None, out_dir=OUTPUT_DIR):\n \"\"\"\n 실행 결과 + 설정값을 pipeline_output/run_log.json 에 누적 저장.\n 매번 덮어쓰지 않고 append → 여러 번 돌린 기록이 모두 남음.\n \"\"\"\n EVENT_CLASSES = {'crawling', 'jump', 'tailgating'}\n\n tp = tn = fp = fn = 0\n per_video = []\n for vp in target_videos:\n gt = get_gt_from_path(vp) # Fix1: 폴더명 기준\n det = vmap.get(os.path.basename(vp), set())\n if gt == 'normal':\n status = 'TN' if not det else 'FP'\n if not det: tn += 1\n else: fp += 1\n else:\n if gt in det: status = 'TP'; tp += 1\n else: status = 'FN'; fn += 1\n per_video.append({'file': os.path.basename(vp), 'gt': gt,\n 'detected': sorted(det), 'status': status})\n\n total = tp + tn + fp + fn\n summary = {\n 'label' : label,\n 'timestamp': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),\n 'settings' : settings or {\n 'conf_threshold' : 0.4,\n 'gate_conf' : 0.25,\n 'near_gate_margin' : 0.2,\n 'jump_multiplier' : 5.0,\n 'jump_min_score' : 0.25,\n 'crawl_ar_thresh' : CONFIG_DICT.get('crawl_aspect_ratio_thresh', 1.6),\n 'crawl_min_frames' : CONFIG_DICT.get('crawl_min_frames', 8),\n 'tailgate_min_frames' : 5,\n 'id_remap_frame_thresh': 15,\n 'gate_cache_frames' : GATE_CACHE_FRAMES,\n 'max_files_per_class' : MAX_FILES_PER_CLASS,\n },\n 'metrics': {\n 'total': total, 'TP': tp, 'TN': tn, 'FP': fp, 'FN': fn,\n 'accuracy' : round((tp+tn)/total*100, 1) if total else 0,\n 'recall' : round(tp/(tp+fn)*100, 1) if (tp+fn) else 0,\n 'precision' : round(tp/(tp+fp)*100, 1) if (tp+fp) else 0,\n },\n 'events_detected': len(candidates),\n 'per_video': per_video,\n 'diag_fn' : diag or [],\n }\n\n log_path = os.path.join(out_dir, 'run_log.json')\n existing = []\n if os.path.exists(log_path):\n with open(log_path, 'r', encoding='utf-8') as f:\n try: existing = json.load(f)\n except: existing = []\n existing.append(summary)\n with open(log_path, 'w', encoding='utf-8') as f:\n json.dump(existing, f, indent=2, ensure_ascii=False)\n\n print(f'[run_log] {label} 저장 완료 → {log_path}')\n print(f' Acc={summary[\"metrics\"][\"accuracy\"]}% '\n f'Recall={summary[\"metrics\"][\"recall\"]}% '\n f'Precision={summary[\"metrics\"][\"precision\"]}% '\n f'(TP={tp} TN={tn} FP={fp} FN={fn})')\n return summary\n\nprint('save_run_summary() 정의 완료')" + ], + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "cell-run", + "metadata": {}, + "source": [ + "all_candidates = []\nall_ann_frames = []\nvideo_event_map = {}\nvideo_diag_map = {} # Fix4: 영상별 진단 정보\n\nMAX_FILES = None\ntarget_videos = local_videos[:MAX_FILES] if MAX_FILES else local_videos\nprint(f'처리 대상: {len(target_videos)}/{len(local_videos)}개 파일')\n\nfor vp in tqdm_nb(target_videos, desc='영상 처리'):\n cands, frames, diag = run_pipeline(vp, cfg) # Fix2: 3-tuple\n all_candidates.extend(cands)\n all_ann_frames.extend(frames)\n video_event_map[os.path.basename(vp)] = {c.event_type for c in cands}\n video_diag_map[os.path.basename(vp)] = diag # Fix4\n gt_label = get_gt_from_path(vp) # Fix1\n print(f'GT={gt_label:12s} {os.path.basename(vp)}: {len(cands)}건 감지')\n\nprint(f'\\n전체 감지: {len(all_candidates)}건')\n\n# 결과 + 설정값 자동 저장\nsave_run_summary('baseline', all_candidates, video_event_map, target_videos)\n" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-debug", + "metadata": {}, + "source": [ + "## 8. 디버그 — 단일 영상\n", + "### 8-A. Jump Score 확인" + ] + }, + { + "cell_type": "code", + "id": "cell-debug", + "metadata": {}, + "source": [ + "DEBUG_VIDEO_IDX = 0\n", + "DEBUG_MAX_FRAMES = 500\n", + "\n", + "video_path = local_videos[DEBUG_VIDEO_IDX]\n", + "detector_d = YOLODetector(conf_threshold=0.4)\n", + "gate_det_d = GateDetector(conf_thres=0.25)\n", + "tracker_d = sv.ByteTrack()\n", + "jump_det_d = JumpDetector(window_frames=30, jump_ratio=0.30,\n", + " calibration_frames=90, dynamic_multiplier=3.0)\n", + "\n", + "cached_gate_d = None; gate_cache_age_d = 0\n", + "cap = cv2.VideoCapture(video_path)\n", + "fps_d = cap.get(cv2.CAP_PROP_FPS) or 30.0\n", + "\n", + "score_log = [] # (frame, tid, score, is_jump)\n", + "debug_frames_d = []\n", + "\n", + "for fi_d in range(DEBUG_MAX_FRAMES):\n", + " ret, frame = cap.read()\n", + " if not ret: break\n", + " clean = blur_faces(frame.copy())\n", + "\n", + " raw = detector_d.detect_persons(clean)\n", + " if raw:\n", + " xyxy = np.array([[d.x1, d.y1, d.x2, d.y2] for d in raw], dtype=float)\n", + " sv_d2 = sv.Detections(xyxy=xyxy, confidence=np.array([d.confidence for d in raw]))\n", + " else:\n", + " sv_d2 = sv.Detections.empty()\n", + " sv_d2 = tracker_d.update_with_detections(sv_d2)\n", + " persons_d = [TrackedPerson(int(sv_d2.tracker_id[i]), *[float(v) for v in sv_d2.xyxy[i]])\n", + " for i in range(len(sv_d2))] if sv_d2.tracker_id is not None else []\n", + "\n", + " if gate_det_d.enabled:\n", + " ng = gate_det_d.detect_best(clean)\n", + " if ng: cached_gate_d = ng; gate_cache_age_d = 0\n", + " else:\n", + " gate_cache_age_d += 1\n", + " if gate_cache_age_d > GATE_CACHE_FRAMES: cached_gate_d = None\n", + "\n", + " active = set()\n", + " if sv_d2.tracker_id is not None:\n", + " for i, tid in enumerate(sv_d2.tracker_id):\n", + " active.add(int(tid))\n", + " jump_det_d.update(int(tid), sv_d2.xyxy[i])\n", + " jump_det_d.cleanup(active)\n", + "\n", + " cfg_d = copy.deepcopy(cfg)\n", + " if cached_gate_d: _update_cfg_from_gate(cfg_d, cached_gate_d)\n", + "\n", + " for p in persons_d:\n", + " score = jump_det_d.get_score(p.track_id)\n", + " is_j = jump_det_d.is_jump(p.track_id)\n", + " score_log.append((fi_d+1, p.track_id, round(score, 3), is_j))\n", + "\n", + " if fi_d % 15 == 0:\n", + " vis = frame.copy()\n", + " jump_scores_d = {p.track_id: jump_det_d.get_score(p.track_id) for p in persons_d}\n", + " _draw_overlay(vis, cfg_d, persons_d, gate_bbox=cached_gate_d, jump_scores=jump_scores_d)\n", + " debug_frames_d.append(vis.copy())\n", + "\n", + "cap.release()\n", + "\n", + "score_log.sort(key=lambda x: -x[2])\n", + "print(f'상위 jump score (총 {len(score_log)}건 중 상위 20):')\n", + "for row in score_log[:20]:\n", + " flag = '>>> JUMP' if row[3] else ''\n", + " print(f' frame={row[0]:4d} ID={row[1]} score={row[2]:.3f} {flag}')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "cell-debug-vis", + "metadata": {}, + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "show = debug_frames_d[:8]\n", + "cols = min(4, len(show)); rows = (len(show)+cols-1)//cols\n", + "if show:\n", + " fig, axes = plt.subplots(rows, cols, figsize=(cols*4, rows*3))\n", + " axes = np.array(axes).flatten() if len(show) > 1 else [axes]\n", + " for ax, vis in zip(axes, show):\n", + " ax.imshow(cv2.cvtColor(vis, cv2.COLOR_BGR2RGB)); ax.axis('off')\n", + " for ax in axes[len(show):]: ax.axis('off')\n", + " plt.suptitle(f'Jump Score 디버그 — {os.path.basename(video_path)}', fontsize=11)\n", + " plt.tight_layout(); plt.show()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-debug-rules", + "metadata": {}, + "source": [ + "### 8-B. Rule 상태 디버그 (tailgating/crawling/unpaid가 안 잡힐 때)" + ] + }, + { + "cell_type": "code", + "id": "cell-debug-rules", + "metadata": {}, + "source": [ + "# verbose_rules=True로 실행하면 30프레임마다 각 사람의 rule 조건 상태를 출력합니다\n", + "# 어떤 조건이 안 맞아서 이벤트가 안 잡히는지 확인용\n", + "DEBUG_RULE_VIDEO_IDX = 0\n", + "\n", + "print(f'Rule 디버그: {os.path.basename(local_videos[DEBUG_RULE_VIDEO_IDX])}')\n", + "print('cy(line=N): 사람 중심 y / pass_line y')\n", + "print('ar: aspect_ratio (standing≈2~4, crawling<1.5)')\n", + "print('in_crawl: crawl_zone 안에 있는지')\n", + "print('in_gate: gate_zone 안에 있는지')\n", + "print('-'*60)\n", + "\n", + "cands_d, _ = run_pipeline(\n", + " local_videos[DEBUG_RULE_VIDEO_IDX],\n", + " cfg,\n", + " output_dir=OUTPUT_DIR,\n", + " verbose_rules=True\n", + ")\n", + "print(f'\\n감지 결과: {len(cands_d)}건')\n", + "for c in cands_d:\n", + " print(f' [{c.event_type}] {c.reason}')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-compare", + "metadata": {}, + "source": "### 8-C. yolo11n vs yolo11s 비교 (ID switch 빈도 + 속도)" + }, + { + "cell_type": "code", + "id": "cell-compare", + "metadata": {}, + "source": "import time, csv\n\nCOMPARE_MAX_FRAMES = 300 # 영상당 비교 프레임 수\n\n# normal / tailgating 영상 자동 선택 (각 클래스 최대 3개)\nTARGET_CLASSES = {'normal', 'nomal', 'tailgating'}\ncompare_videos = []\nfor vp in local_videos:\n folder = os.path.basename(os.path.dirname(vp)).lower()\n # 로컬 복사 파일명에서 클래스 추출\n fname = os.path.basename(vp).lower()\n matched = any(cls in fname for cls in TARGET_CLASSES)\n if matched and len([v for v in compare_videos if any(cls in os.path.basename(v).lower() for cls in TARGET_CLASSES)]) < 6:\n compare_videos.append(vp)\n\nif not compare_videos:\n # fallback: 앞에서 3개\n compare_videos = local_videos[:3]\n\nprint(f'비교 대상 영상 {len(compare_videos)}개:')\nfor v in compare_videos:\n print(f' {os.path.basename(v)}')\nprint('-' * 60)\n\ndef run_tracker_compare(model_name, video_path, max_frames, conf=0.4):\n model = YOLO(model_name)\n tracker = sv.ByteTrack(lost_track_buffer=30)\n cap = cv2.VideoCapture(video_path)\n all_tids, prev_tids = set(), set()\n id_switches = 0\n times = []\n\n for fi in range(max_frames):\n ret, frame = cap.read()\n if not ret:\n break\n t0 = time.perf_counter()\n results = model(frame, classes=[0], conf=conf, verbose=False)\n dets = []\n for r in results:\n if r.boxes is None:\n continue\n for box in r.boxes:\n x1, y1, x2, y2 = box.xyxy[0].tolist()\n dets.append([x1, y1, x2, y2, float(box.conf[0])])\n if dets:\n xyxy = np.array([[d[0], d[1], d[2], d[3]] for d in dets])\n confs = np.array([d[4] for d in dets])\n sv_d = sv.Detections(xyxy=xyxy, confidence=confs)\n else:\n sv_d = sv.Detections.empty()\n sv_d = tracker.update_with_detections(sv_d)\n times.append(time.perf_counter() - t0)\n\n curr_tids = set(int(t) for t in sv_d.tracker_id) if sv_d.tracker_id is not None else set()\n if prev_tids:\n id_switches += len(curr_tids - all_tids)\n all_tids.update(curr_tids)\n prev_tids = curr_tids\n\n cap.release()\n avg_ms = float(np.mean(times)) * 1000 if times else 0\n return {\n 'video' : os.path.basename(video_path),\n 'model' : model_name,\n 'total_unique_ids': len(all_tids),\n 'id_switches' : id_switches,\n 'avg_ms' : round(avg_ms, 1),\n 'fps' : round(1000 / avg_ms, 1) if avg_ms > 0 else 0,\n 'frames' : len(times),\n }\n\nall_results = []\nmodels = ['yolo11n.pt', 'yolo11s.pt']\n\nfor vp in compare_videos:\n print(f'\\n[{os.path.basename(vp)}]')\n for model_name in models:\n print(f' {model_name} ...', end=' ', flush=True)\n r = run_tracker_compare(model_name, vp, COMPARE_MAX_FRAMES)\n all_results.append(r)\n print(f'ID={r[\"total_unique_ids\"]} switch={r[\"id_switches\"]} {r[\"avg_ms\"]}ms')\n\n# 요약 출력\nprint('\\n' + '=' * 65)\nprint(f'{\"영상\":<30} {\"모델\":<12} {\"고유ID\":>6} {\"switch\":>7} {\"ms\":>7} {\"FPS\":>6}')\nprint('-' * 65)\nfor r in all_results:\n print(f'{r[\"video\"][:30]:<30} {r[\"model\"]:<12} {r[\"total_unique_ids\"]:>6} '\n f'{r[\"id_switches\"]:>7} {r[\"avg_ms\"]:>6.1f}ms {r[\"fps\"]:>5.1f}')\n\n# Drive에 CSV 저장\ncsv_path = f'{DRIVE_SAVE}/model_compare.csv'\nos.makedirs(DRIVE_SAVE, exist_ok=True)\nwith open(csv_path, 'w', newline='', encoding='utf-8') as f:\n writer = csv.DictWriter(f, fieldnames=all_results[0].keys())\n writer.writeheader()\n writer.writerows(all_results)\nprint(f'\\nCSV 저장 → {csv_path}')\n\n# n vs s 평균 비교\nfor model_name in models:\n rows = [r for r in all_results if r['model'] == model_name]\n if rows:\n avg_id = sum(r['total_unique_ids'] for r in rows) / len(rows)\n avg_sw = sum(r['id_switches'] for r in rows) / len(rows)\n avg_fps = sum(r['fps'] for r in rows) / len(rows)\n print(f'{model_name}: 평균 고유ID={avg_id:.1f} switch={avg_sw:.1f} FPS={avg_fps:.1f}')\n", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-results", + "metadata": {}, + "source": [ + "## 9. 결과 확인" + ] + }, + { + "cell_type": "code", + "id": "cell-results", + "metadata": {}, + "source": [ + "import pandas as pd\n", + "from collections import Counter\n", + "\n", + "if all_candidates:\n", + " df = pd.DataFrame([{\n", + " 'event_type': c.event_type, 'start': c.start_frame, 'end': c.end_frame,\n", + " 'tracks': str(c.track_ids), 'conf': round(c.confidence, 3),\n", + " 'status': c.status, 'reason': c.reason\n", + " } for c in all_candidates])\n", + " display(df)\n", + " cnt = Counter(c.event_type for c in all_candidates)\n", + " plt.figure(figsize=(8, 3))\n", + " plt.bar(cnt.keys(), cnt.values(), color=['#e74c3c', '#e67e22', '#3498db', '#2ecc71'])\n", + " plt.title('이벤트 유형별'); plt.ylabel('count'); plt.tight_layout(); plt.show()\n", + "else:\n", + " print('감지된 이벤트 없음')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "cell-ann-frames", + "metadata": {}, + "source": [ + "if all_ann_frames:\n", + " show = all_ann_frames[:8]\n", + " cols = min(4, len(show)); rows = (len(show)+cols-1)//cols\n", + " fig, axes = plt.subplots(rows, cols, figsize=(cols*4, rows*3))\n", + " axes = np.array(axes).flatten() if len(show) > 1 else [axes]\n", + " for ax, (fi, et, vis) in zip(axes, show):\n", + " ax.imshow(cv2.cvtColor(vis, cv2.COLOR_BGR2RGB))\n", + " ax.set_title(f'{et.upper()} @{fi}', fontsize=9); ax.axis('off')\n", + " for ax in axes[len(show):]: ax.axis('off')\n", + " plt.tight_layout(); plt.show()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "46fb3bbf", + "source": "### 9-B. 폴더명(정답) vs 감지 결과 매칭 확인", + "metadata": {} + }, + { + "cell_type": "code", + "id": "c655a6f5", + "source": [ + "# ─── Fix 1: 정답 라벨 = 상위 폴더명 기준 ────────────────────────────\ndef get_gt_from_path(vp):\n \"\"\"영상 경로에서 정답 라벨 추출. 상위 폴더명이 우선, fallback=파일명.\"\"\"\n folder = os.path.basename(os.path.dirname(vp)).lower()\n if folder in {'jump', 'crawling', 'tailgating'}:\n return folder\n if folder in {'normal', 'nomal'}: # 오타 'nomal' 포함\n return 'normal'\n # fallback: 파일명 기반\n fname = os.path.basename(vp).lower()\n for cls in ('jump', 'crawling', 'tailgating'):\n if cls in fname:\n return cls\n return 'normal'\n\nEVENT_CLASSES = {'crawling', 'jump', 'tailgating'}\n\nmatch = mismatch = fn = fp = 0\nrows = []\nfp_files = []; fn_files = []; wrong_files = []\n\nfor vp in target_videos:\n gt = get_gt_from_path(vp) # ★ 폴더명 기준\n detected = video_event_map.get(os.path.basename(vp), set())\n diag = video_diag_map.get(os.path.basename(vp), {})\n\n if gt == 'normal':\n if not detected:\n status = '✅ TN'; match += 1\n else:\n status = f'❌ FP ({chr(44).join(sorted(detected))})'; fp += 1\n fp_files.append((os.path.basename(vp), detected, diag))\n else:\n if gt in detected:\n status = '✅ TP'; match += 1\n elif detected:\n status = f'⚠️ WRONG ({chr(44).join(sorted(detected))})'; mismatch += 1\n wrong_files.append((os.path.basename(vp), gt, detected, diag))\n else:\n status = '❌ FN (미탐지)'; fn += 1\n fn_files.append((os.path.basename(vp), gt, diag))\n\n rows.append((status, gt, ', '.join(sorted(detected)) if detected else '없음', os.path.basename(vp)))\n\nfor status, gt, det, fname in rows:\n print(f'{status:32s} GT={gt:12s} det={det:22s} {fname[:35]}')\n\ntotal = len(rows)\nprint(f'\\n총 {total}개 영상')\nprint(f' ✅ 정답 (TP+TN): {match} ({100*match//total if total else 0}%)')\nprint(f' ❌ FN 미탐지 : {fn}')\nprint(f' ❌ FP 오탐 : {fp}')\nprint(f' ⚠️ Wrong class : {mismatch}')\n\n# ── Fix 4: 실패 파일 + 진단 정보 출력 ─────────────────────────────\nif fn_files:\n print(f'\\n─── FN 목록 ({len(fn_files)}건) ──────────────────────────────')\n for fname, gt, d in fn_files:\n gate_ok = f'gate={d.get(\"gate_found_frames\",0)}/{d.get(\"total_frames\",0)}f'\n tracks = f'tracks={len(d.get(\"unique_tids\",set()))}'\n switches = f'id_sw={d.get(\"id_switches\",0)}'\n pk_val = max(d['jump_peak_scores'].values()) if d.get('jump_peak_scores') else 0.0\n jpeak = f'jump_peak={pk_val:.2f}'\n tsteak = f'tail_max={d.get(\"tail_max_streak\",0)}'\n print(f' GT={gt:12s} | {gate_ok} {tracks} {switches} {jpeak} {tsteak} | {fname}')\n\nif fp_files:\n print(f'\\n─── FP 목록 ({len(fp_files)}건) ──────────────────────────────')\n for fname, det, d in fp_files:\n gate_ok = f'gate={d.get(\"gate_found_frames\",0)}/{d.get(\"total_frames\",0)}f'\n tracks = f'tracks={len(d.get(\"unique_tids\",set()))}'\n print(f' det={chr(44).join(sorted(det)):20s} | {gate_ok} {tracks} | {fname}')\n\nif wrong_files:\n print(f'\\n─── WRONG 목록 ({len(wrong_files)}건) ─────────────────────────')\n for fname, gt, det, d in wrong_files:\n print(f' GT={gt:12s} det={chr(44).join(sorted(det)):20s} | {fname}')\n" + ], + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "id": "md-fp-analysis", + "cell_type": "markdown", + "source": [ + "### 9-C. FP 분석 — Normal 오탐 원인 파악\n\n`run_pipeline` 결과에서 Normal 영상에 감지된 이벤트를 타입별로 분해하고,\n각 이벤트 `reason` 필드를 파싱해 오탐 원인과 임계값 조정 방향을 자동 제안합니다." + ], + "metadata": {} + }, + { + "id": "cell-fp-breakdown", + "cell_type": "code", + "source": [ + "import re\nfrom collections import defaultdict\n\n# ── Normal FP 영상 + EventCandidate 수집 ────────────────────────────\nfp_normal_videos = set()\nfor vp in target_videos:\n if get_gt_from_path(vp) == 'normal':\n det = video_event_map.get(os.path.basename(vp), set())\n if det:\n fp_normal_videos.add(os.path.basename(vp))\n\n# all_candidates 에서 FP 영상에 해당하는 이벤트만 필터\nfp_candidates_by_evt = defaultdict(list) # evt → [(video_name, EventCandidate, diag)]\nfor c in all_candidates:\n # reason 안에 파일명이 없으므로 clip 경로 또는 track_ids로 역추적이 어렵다\n # → video_event_map에서 FP 영상만 뽑아 target_videos 순서로 매칭\n pass\n\n# 더 직접적인 방법: (vp, EventCandidate) 쌍으로 run_pipeline 재실행 없이 재구성\n# cell-run에서 per-video cands를 분리 저장하지 않았으므로\n# video_event_map + video_diag_map으로 통계만 집계\n\nfp_by_evt = defaultdict(list) # evt → [(basename, diag)]\nfor vp in target_videos:\n if get_gt_from_path(vp) != 'normal': continue\n detected = video_event_map.get(os.path.basename(vp), set())\n if not detected: continue\n diag = video_diag_map.get(os.path.basename(vp), {})\n for evt in detected:\n fp_by_evt[evt].append((os.path.basename(vp), diag))\n\ntotal_fp_cnt = sum(len(v) for v in fp_by_evt.values())\nfp_video_cnt = len([vp for vp in target_videos\n if get_gt_from_path(vp) == 'normal'\n and video_event_map.get(os.path.basename(vp))])\nprint(f'Normal FP: {fp_video_cnt}개 영상 / 이벤트 {total_fp_cnt}건')\nprint()\n\nfor evt in ['jump', 'crawling', 'tailgating']:\n items = fp_by_evt.get(evt, [])\n if not items:\n print(f'[{evt.upper():10s}] FP 없음')\n continue\n\n print(f'══ {evt.upper()} FP: {len(items)}건 ═══════════════════════════════════════')\n jpeaks = []\n tail_maxes = []\n gate_rates = []\n id_sws = []\n\n for fname, d in items:\n tf = max(d.get('total_frames', 1), 1)\n gf = d.get('gate_found_frames', 0)\n gate_rate = gf / tf\n n_tracks = len(d.get('unique_tids', set()))\n id_sw = d.get('id_switches', 0)\n pk = max(d['jump_peak_scores'].values()) if d.get('jump_peak_scores') else 0.0\n t_streak = d.get('tail_max_streak', 0)\n\n gate_rates.append(gate_rate)\n jpeaks.append(pk)\n tail_maxes.append(t_streak)\n id_sws.append(id_sw)\n\n print(f' gate={gf}/{tf}f({gate_rate:.0%}) tracks={n_tracks} '\n f'id_sw={id_sw} jump_peak={pk:.2f} tail_streak={t_streak} {fname}')\n\n print()\n # ── 원인 진단 ────────────────────────────────────────────────\n avg_gate = sum(gate_rates)/len(gate_rates)\n avg_ids = sum(id_sws)/len(id_sws)\n\n if evt == 'jump':\n avg_pk = sum(jpeaks)/len(jpeaks)\n below35 = sum(1 for v in jpeaks if v < 0.35)\n below40 = sum(1 for v in jpeaks if v < 0.40)\n print(f' [JUMP 분석] jump_peak: min={min(jpeaks):.2f} avg={avg_pk:.2f} max={max(jpeaks):.2f}')\n print(f' peak < 0.35: {below35}/{len(jpeaks)}건 peak < 0.40: {below40}/{len(jpeaks)}건')\n if below35 >= len(jpeaks) * 0.6:\n print(f' ★ 제안: JUMP_MIN_SCORE 0.25 → 0.35 (FP {below35}건 제거 예상)')\n elif below40 >= len(jpeaks) * 0.6:\n print(f' ★ 제안: JUMP_MIN_SCORE 0.25 → 0.40 (FP {below40}건 제거 예상)')\n else:\n print(f' ★ jump_peak가 높음 → 실제로 사람이 뛰는 모션 존재 → EfficientNet 필터 필요')\n\n elif evt == 'crawling':\n print(f' [CRAWL 분석] gate_rate: avg={avg_gate:.0%} id_switches: avg={avg_ids:.1f}')\n if avg_gate < 0.3:\n print(f' ★ gate가 자주 안 잡힘 → gate 없을 때 near_gate_x 필터가 fallback zone 기준으로 동작')\n print(f' → gate_conf 낮추거나 gate_best.pt 재학습 고려')\n else:\n print(f' ★ gate는 정상 탐지 → crawl_min_frames 올리거나 AR thresh 높이기')\n print(f' 또는 EfficientNet이 normal로 판정해 제거되는지 확인')\n\n elif evt == 'tailgating':\n avg_streak = sum(tail_maxes)/len(tail_maxes)\n below8 = sum(1 for v in tail_maxes if v < 8)\n below10 = sum(1 for v in tail_maxes if v < 10)\n print(f' [TAIL 분석] tail_streak: min={min(tail_maxes)} avg={avg_streak:.1f} max={max(tail_maxes)}')\n print(f' streak < 8: {below8}/{len(tail_maxes)}건 streak < 10: {below10}/{len(tail_maxes)}건')\n if below8 >= len(tail_maxes) * 0.5:\n print(f' ★ 제안: TAILGATE_MIN_FRAMES 5 → 8 (FP {below8}건 제거 예상)')\n elif below10 >= len(tail_maxes) * 0.5:\n print(f' ★ 제안: TAILGATE_MIN_FRAMES 5 → 10 (FP {below10}건 제거 예상)')\n else:\n print(f' ★ streak이 높음 → 실제로 두 사람이 오래 겹쳐 있음 → dist/방향 필터 강화 or EfficientNet')\n\n print()\n" + ], + "metadata": {}, + "outputs": [], + "execution_count": null + }, + { + "id": "md-fp-cause", + "cell_type": "markdown", + "source": [ + "#### FP 원인별 임계값 시뮬레이션\n\n아래 셀에서 Normal 필터 강화 후 TP/TN/FP/FN 변화를 시뮬레이션합니다.\n실제 재실행 없이 `video_diag_map`과 `video_event_map`만으로 계산합니다." + ], + "metadata": {} + }, + { + "id": "cell-fp-sim", + "cell_type": "code", + "source": [ + "# ── 필터 파라미터 (여기서만 수정) ──────────────────────────────────\nSIM_JUMP_MIN_SCORE_STRICT = 0.35 # 현재 0.25\nSIM_TAILGATE_MIN_FRAMES_STRICT = 8 # 현재 5\nSIM_CRAWL_MIN_FRAMES_STRICT = 12 # 현재 10\n# ───────────────────────────────────────────────────────────────────\n\ndef sim_normal_filter(vp, detected, diag,\n jump_score_thresh=SIM_JUMP_MIN_SCORE_STRICT,\n tail_frames_thresh=SIM_TAILGATE_MIN_FRAMES_STRICT,\n crawl_frames_thresh=SIM_CRAWL_MIN_FRAMES_STRICT):\n \"\"\"\n diag만으로 이 영상이 강화 필터를 통과할지 시뮬레이션.\n 반환: 통과하는 이벤트 집합 (빈 set이면 TN 처리)\n \"\"\"\n passed = set()\n for evt in detected:\n if evt == 'jump':\n pk = max(diag['jump_peak_scores'].values()) if diag.get('jump_peak_scores') else 0.0\n if pk >= jump_score_thresh:\n passed.add(evt)\n elif evt == 'tailgating':\n if diag.get('tail_max_streak', 0) >= tail_frames_thresh:\n passed.add(evt)\n elif evt == 'crawling':\n # crawl_max_consec 정보가 diag에 없으므로 EfficientNet 의존 표시\n passed.add('crawling_eff_dep') # EfficientNet에 위임\n return passed\n\n# ── 시뮬레이션 실행 ───────────────────────────────────────────────\ntp_base = tp_sim = 0\ntn_base = tn_sim = 0\nfp_base = fp_sim = 0\nfn_base = fn_sim = 0\n\nremoved_fp = []; survived_fp = []\n\nfor vp in target_videos:\n gt = get_gt_from_path(vp)\n detected = video_event_map.get(os.path.basename(vp), set())\n diag = video_diag_map.get(os.path.basename(vp), {})\n\n # ── Baseline ──\n if gt == 'normal':\n if not detected: tn_base += 1\n else: fp_base += 1\n else:\n if gt in detected: tp_base += 1\n else: fn_base += 1\n\n # ── Simulated ──\n if gt == 'normal':\n passed = sim_normal_filter(vp, detected, diag)\n passed_real = {e for e in passed if e != 'crawling_eff_dep'}\n if not passed_real:\n tn_sim += 1\n if detected: removed_fp.append((os.path.basename(vp), detected, diag))\n else:\n fp_sim += 1\n survived_fp.append((os.path.basename(vp), passed_real, diag))\n else:\n passed = sim_normal_filter(vp, detected, diag)\n if gt in detected:\n # TP였던 것도 필터가 제거할 수 있음 — 확인\n if gt == 'jump':\n pk = max(diag['jump_peak_scores'].values()) if diag.get('jump_peak_scores') else 0.0\n if pk >= SIM_JUMP_MIN_SCORE_STRICT: tp_sim += 1\n else: fn_sim += 1 # 필터가 TP도 제거\n elif gt == 'tailgating':\n if diag.get('tail_max_streak',0) >= SIM_TAILGATE_MIN_FRAMES_STRICT: tp_sim += 1\n else: fn_sim += 1\n else:\n tp_sim += 1 # crawling은 EfficientNet 위임 → TP 유지\n else:\n fn_sim += 1\n\ntotal = len(target_videos)\nprint('=' * 55)\nprint(f'{\"\":20s} {\"Baseline\":>12} {\"Filtered\":>12}')\nprint('=' * 55)\nfor k, bv, sv in [('TP', tp_base, tp_sim), ('TN', tn_base, tn_sim),\n ('FP', fp_base, fp_sim), ('FN', fn_base, fn_sim)]:\n mark = ' ✅' if (k in ('TP','TN') and sv >= bv) else \\\n ' ✅' if (k in ('FP','FN') and sv <= bv) else \\\n ' ❌' if (k in ('TP','TN') and sv < bv) else \\\n ' ❌' if (k in ('FP','FN') and sv > bv) else ''\n print(f'{k:20s} {bv:>12} {sv:>12}{mark}')\n\nacc_b = round((tp_base+tn_base)/(tp_base+tn_base+fp_base+fn_base)*100,1)\nacc_s = round((tp_sim +tn_sim) /(tp_sim +tn_sim +fp_sim +fn_sim )*100,1)\nprec_b = round(tp_base/(tp_base+fp_base)*100,1) if (tp_base+fp_base) else 0\nprec_s = round(tp_sim /(tp_sim +fp_sim )*100,1) if (tp_sim +fp_sim ) else 0\nrec_b = round(tp_base/(tp_base+fn_base)*100,1) if (tp_base+fn_base) else 0\nrec_s = round(tp_sim /(tp_sim +fn_sim )*100,1) if (tp_sim +fn_sim ) else 0\nprint(f'{\"Accuracy\":20s} {acc_b:>11}% {acc_s:>11}%')\nprint(f'{\"Precision\":20s} {prec_b:>11}% {prec_s:>11}%')\nprint(f'{\"Recall\":20s} {rec_b:>11}% {rec_s:>11}%')\nprint()\n\nif removed_fp:\n print(f'[제거된 FP] {len(removed_fp)}건:')\n for fname, det, d in removed_fp:\n pk = max(d['jump_peak_scores'].values()) if d.get('jump_peak_scores') else 0.0\n print(f' det={sorted(det)} jump_peak={pk:.2f} tail_streak={d.get(\"tail_max_streak\",0)} {fname}')\n\nif survived_fp:\n print(f'\\n[남은 FP] {len(survived_fp)}건 → 추가 조치 필요:')\n for fname, det, d in survived_fp:\n print(f' det={sorted(det)} {fname}')\n print()\n print('[다음 단계 제안]')\n evt_counts = {}\n for _, det, _ in survived_fp:\n for e in det: evt_counts[e] = evt_counts.get(e, 0) + 1\n for evt, cnt in sorted(evt_counts.items(), key=lambda x: -x[1]):\n if evt == 'jump':\n print(f' JUMP {cnt}건 남음 → 3프레임 연속 조건 이미 있음 → EfficientNet 강화 (EFF_CONF_THRESHOLD 낮추기)')\n elif evt == 'crawling':\n print(f' CRAWL {cnt}건 남음 → EfficientNet이 주요 필터 (crawling vs normal 판정 확인)')\n elif evt == 'tailgating':\n print(f' TAIL {cnt}건 남음 → 실제 영상 재생으로 \"왜 두 사람이 겹쳤는지\" 확인 후 데이터 수집')\n" + ], + "metadata": {}, + "outputs": [], + "execution_count": null + }, + { + "id": "md-next-steps", + "cell_type": "markdown", + "source": [ + "#### 9-C 분석 후 다음 단계\n\n| 상황 | 조치 |\n|------|------|\n| Jump FP peak < 0.35 다수 | `JUMP_MIN_SCORE` 올리기 → section 10-B 재실행 |\n| Jump FP peak ≥ 0.35 다수 | EfficientNet `EFF_CONF_THRESHOLD` 낮추기 (0.50→0.40) |\n| Tailgating FP streak < 8 | `TAILGATE_MIN_FRAMES` 올리기 → section 10-B 재실행 |\n| Tailgating FP streak ≥ 8 | 데이터 수집 필요 (normal vs tailgating 경계 케이스) |\n| Crawling FP 다수 | `CRAWL_MIN_FRAMES` / `CRAWL_AR_THRESH` 조정 + EfficientNet 확인 |\n| Normal FP 줄었지만 Recall 하락 | EfficientNet 재학습 (더 많은 데이터로) 고려 |\n" + ], + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "9fd0c3bd", + "source": "## 10. FN 진단 — 놓친 영상 원인 분류\n\nFN 영상만 다시 돌려서 왜 놓쳤는지 자동 분류합니다.\n\n**원인 분류 기준 (순서대로 판정)**\n1. `person_detection` — 영상 전체에서 사람이 한 번도 탐지 안 됨\n2. `gate_detection` — gate bbox 한 번도 탐지 안 됨\n3. `gate_area_filter` — 사람은 잡혔는데 gate x-range 조건 탈락\n4. `rule_threshold` — gate 근처까지 왔는데 rule 조건 미달 (score/AR/streak 로그 포함)\n5. `id_switch_suspected` — 고유 ID 수 과다 (실제 사람 수 대비 2배 이상)", + "metadata": {} + }, + { + "cell_type": "code", + "id": "88eb88ab", + "source": "def run_pipeline_diag(video_path, cfg_base, gt_class, conf=0.4):\n \"\"\"\n FN 영상을 재처리하며 실패 원인을 진단한다.\n 반환: dict — cause, detail, 각종 max 지표\n \"\"\"\n import copy\n cfg = copy.deepcopy(cfg_base)\n\n detector = YOLODetector(conf_threshold=conf)\n gate_det = GateDetector(conf_thres=0.25)\n tracker = sv.ByteTrack()\n jump_det = JumpDetector(window_frames=30, calibration_frames=90,\n dynamic_multiplier=5.0, min_score=0.25)\n\n cap = cv2.VideoCapture(video_path)\n fps = cap.get(cv2.CAP_PROP_FPS) or 30.0\n total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))\n\n # 진단용 누적 변수\n person_counts = [] # 프레임별 탐지 인원\n gate_det_frames = 0 # gate 탐지된 프레임 수\n near_gate_per_tid = defaultdict(int) # tid → gate 근처 프레임 수\n jump_score_per_tid = defaultdict(float) # tid → 최대 jump score\n ar_per_tid = defaultdict(list) # tid → AR 리스트\n cooccupy_streak = 0 # tailgating용 최대 연속 점유 프레임\n _streak_cur = 0\n unique_ids = set()\n cached_gate = None; gate_cache_age = 0\n\n for fi in range(total or 9999):\n ret, frame = cap.read()\n if not ret: break\n\n clean = blur_faces(frame.copy())\n\n raw = detector.detect_persons(clean)\n person_counts.append(len(raw))\n\n if raw:\n xyxy = np.array([[d.x1, d.y1, d.x2, d.y2] for d in raw], dtype=float)\n sv_d = sv.Detections(xyxy=xyxy, confidence=np.array([d.confidence for d in raw]))\n else:\n sv_d = sv.Detections.empty()\n sv_d = tracker.update_with_detections(sv_d)\n persons = [TrackedPerson(int(sv_d.tracker_id[i]), *[float(v) for v in sv_d.xyxy[i]])\n for i in range(len(sv_d))] if sv_d.tracker_id is not None else []\n\n for p in persons:\n unique_ids.add(p.track_id)\n\n # gate 탐지\n if gate_det.enabled:\n ng = gate_det.detect_best(clean)\n if ng:\n cached_gate = ng; gate_cache_age = 0\n _update_cfg_from_gate(cfg, cached_gate)\n jump_det.set_gate_height(ng['y2'] - ng['y1'])\n gate_det_frames += 1\n else:\n gate_cache_age += 1\n if gate_cache_age > GATE_CACHE_FRAMES:\n cached_gate = None\n\n # jump score, AR, near_gate 누적\n if sv_d.tracker_id is not None:\n for i, tid in enumerate(sv_d.tracker_id):\n tid = int(tid)\n jump_det.update(tid, sv_d.xyxy[i])\n\n for p in persons:\n score = jump_det.get_score(p.track_id)\n jump_score_per_tid[p.track_id] = max(jump_score_per_tid[p.track_id], score)\n ar_per_tid[p.track_id].append(p.aspect_ratio)\n if near_gate_x(p, cached_gate, margin_ratio=0.2):\n near_gate_per_tid[p.track_id] += 1\n\n # tailgating streak\n if cached_gate:\n gx1, gy1, gx2, gy2 = cached_gate['x1'], cached_gate['y1'], cached_gate['x2'], cached_gate['y2']\n in_gate = [p for p in persons if gx1 <= p.cx <= gx2 and gy1 <= p.cy <= gy2]\n if len(in_gate) >= 2:\n _streak_cur += 1\n cooccupy_streak = max(cooccupy_streak, _streak_cur)\n else:\n _streak_cur = 0\n\n jump_det.cleanup({p.track_id for p in persons})\n\n cap.release()\n\n # ── 원인 판정 ──────────────────────────────────────────────\n max_persons = max(person_counts) if person_counts else 0\n any_near_gate = any(v > 0 for v in near_gate_per_tid.values())\n max_jump = max(jump_score_per_tid.values()) if jump_score_per_tid else 0.0\n min_ar = min(np.median(v) for v in ar_per_tid.values()) if ar_per_tid else 99.0\n jump_thresh_est = jump_det._dynamic_threshold or jump_det._gate_min\n\n if max_persons == 0:\n cause = 'person_detection'\n detail = '전체 프레임에서 사람 미탐지'\n elif gate_det_frames == 0:\n cause = 'gate_detection'\n detail = f'gate bbox 미탐지 (전체 {total}f)'\n elif not any_near_gate:\n cause = 'gate_area_filter'\n detail = f'사람 탐지됨(max={max_persons}) but gate x-range 조건 탈락'\n elif gt_class == 'jump' and max_jump < 0.25:\n cause = 'rule_threshold'\n detail = f'jump max_score={max_jump:.3f} < min_score=0.25'\n elif gt_class == 'jump' and max_jump >= 0.25:\n cause = 'rule_threshold'\n detail = f'jump score OK({max_jump:.3f}) but threshold 미달 (est={jump_thresh_est:.1f}px)'\n elif gt_class == 'crawling' and min_ar > cfg.crawl_aspect_ratio_thresh:\n cause = 'rule_threshold'\n detail = f'crawling min_AR={min_ar:.2f} > thresh={cfg.crawl_aspect_ratio_thresh}'\n elif gt_class == 'tailgating' and cooccupy_streak < 5:\n cause = 'rule_threshold'\n detail = f'tailgating max_streak={cooccupy_streak} < 5'\n elif len(unique_ids) > max(3, total / fps * 2):\n cause = 'id_switch_suspected'\n detail = f'고유 ID={len(unique_ids)}개 (영상 {total/fps:.0f}s, 기대치 대비 과다)'\n else:\n cause = 'rule_threshold'\n detail = f'원인 불명 — jump={max_jump:.3f} AR={min_ar:.2f} streak={cooccupy_streak}'\n\n return {\n 'video' : os.path.basename(video_path),\n 'gt' : gt_class,\n 'cause' : cause,\n 'detail' : detail,\n 'max_persons' : max_persons,\n 'gate_det_f' : gate_det_frames,\n 'max_jump_score': round(max_jump, 3),\n 'min_AR' : round(min_ar, 3),\n 'max_streak' : cooccupy_streak,\n 'unique_ids' : len(unique_ids),\n 'total_frames' : total,\n }\n\nprint('run_pipeline_diag() 정의 완료')", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "c3e8ad78", + "source": [ + "import csv\nfrom collections import Counter\n\nEVENT_CLASSES = {'crawling', 'jump', 'tailgating'}\n\n# 9-B에서 만든 rows 재사용: FN 영상만 추출\nfn_videos = []\nfor vp in target_videos:\n gt = get_gt_from_path(vp) # Fix1: 폴더명 기준\n if gt == 'normal':\n continue\n detected = video_event_map.get(os.path.basename(vp), set())\n if gt not in detected:\n fn_videos.append((vp, gt))\n\nprint(f'FN 영상: {len(fn_videos)}개')\nfor vp, gt in fn_videos:\n print(f' [{gt}] {os.path.basename(vp)}')\n\n# FN 영상 진단 실행\ndiag_results = []\nfor vp, gt in tqdm_nb(fn_videos, desc='FN 진단'):\n result = run_pipeline_diag(vp, cfg, gt)\n diag_results.append(result)\n print(f' [{result[\"cause\"]}] {result[\"video\"][:40]} — {result[\"detail\"]}')\n\n# 원인별 집계\ncause_counter = Counter(r['cause'] for r in diag_results)\nprint('\\n' + '='*60)\nprint('원인별 FN 집계:')\nfor cause, cnt in cause_counter.most_common():\n print(f' {cause:25s}: {cnt}건')\n\ntop_cause = cause_counter.most_common(1)[0][0] if cause_counter else None\nprint(f'\\n→ 가장 많은 원인: [{top_cause}] → 다음 셀에서 수정 방향 제시')" + ], + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "2cb644a2", + "source": "import pandas as pd\n\n# 상세 테이블 출력\nif diag_results:\n df_diag = pd.DataFrame(diag_results)\n display(df_diag[['video','gt','cause','max_persons','gate_det_f',\n 'max_jump_score','min_AR','max_streak','unique_ids','detail']])\n\n# 원인별 자동 수정 가이드 출력\nprint('\\n' + '='*60)\nprint(f'[{top_cause}] 수정 가이드:')\n\nif top_cause == 'person_detection':\n print(' → conf_threshold 낮추기: YOLODetector(conf_threshold=0.3)')\n print(' → 또는 더 큰 모델로 교체: yolo11s.pt')\n\nelif top_cause == 'gate_detection':\n print(' → gate_best.pt conf 낮추기: GateDetector(conf_thres=0.15)')\n print(' → gate 미탐지 영상에서 gate가 화면에 보이는지 먼저 확인 필요')\n print(' → 보인다면 gate_best.pt 재학습 (라벨 추가) 고려')\n\nelif top_cause == 'gate_area_filter':\n print(' → near_gate_x margin_ratio 올리기: 0.2 → 0.4')\n print(' → run_pipeline() 내 near_gate_x 호출부 수정')\n\nelif top_cause == 'rule_threshold':\n # 어떤 클래스가 많은지 세부 분류\n rule_fn = [r for r in diag_results if r['cause'] == 'rule_threshold']\n gt_cnt = Counter(r['gt'] for r in rule_fn)\n print(f' 세부: {dict(gt_cnt)}')\n if gt_cnt.get('jump', 0) >= max(gt_cnt.values()):\n scores = [r['max_jump_score'] for r in rule_fn if r['gt'] == 'jump']\n print(f' jump score 분포: min={min(scores):.3f} mean={sum(scores)/len(scores):.3f} max={max(scores):.3f}')\n print(' → dynamic_multiplier 낮추기: 5.0 → 3.0')\n print(' → 또는 min_score 낮추기: 0.25 → 0.20')\n if gt_cnt.get('crawling', 0) >= max(gt_cnt.values()):\n ars = [r['min_AR'] for r in rule_fn if r['gt'] == 'crawling']\n print(f' crawling min_AR 분포: min={min(ars):.2f} mean={sum(ars)/len(ars):.2f} max={max(ars):.2f}')\n print(' → crawl_aspect_ratio_thresh 올리기: 1.6 → 1.8')\n if gt_cnt.get('tailgating', 0) >= max(gt_cnt.values()):\n streaks = [r['max_streak'] for r in rule_fn if r['gt'] == 'tailgating']\n print(f' tailgating max_streak 분포: min={min(streaks)} mean={sum(streaks)/len(streaks):.1f}')\n print(' → min_cooccupy_frames 낮추기: 5 → 3')\n\nelif top_cause == 'id_switch_suspected':\n print(' → ByteTrack lost_track_buffer 늘리기: sv.ByteTrack(lost_track_buffer=60)')\n print(' → IDRemapper frame_thresh 늘리기: IDRemapper(frame_thresh=30)')\n\n# CSV 저장\ncsv_path = f'{DRIVE_SAVE}/diag_fn.csv'\nos.makedirs(DRIVE_SAVE, exist_ok=True)\nif diag_results:\n with open(csv_path, 'w', newline='', encoding='utf-8') as f:\n writer = csv.DictWriter(f, fieldnames=diag_results[0].keys())\n writer.writeheader()\n writer.writerows(diag_results)\n print(f'\\nCSV 저장 → {csv_path}')", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "d14cd6e0", + "source": "### 10-B. 수정 적용 후 전체 재실행 & 비교\n\n위 가이드 보고 파라미터 수정한 뒤 이 셀 실행 → before/after 비교표 출력", + "metadata": {} + }, + { + "cell_type": "code", + "id": "a1c08ab6", + "source": [ + "# ══════════════════════════════════════════════════════════════════\n# Fix2+3: 여기서 파라미터 수정 → run_pipeline에 전부 전달됩니다\n# ══════════════════════════════════════════════════════════════════\nCONF_THRESHOLD = 0.4\nGATE_CONF = 0.25\nNEAR_GATE_MARGIN = 0.2\nJUMP_MULTIPLIER = 5.0\nJUMP_MIN_SCORE = 0.25\nJUMP_MIN_FRAMES = 3\nCRAWL_AR_THRESH = 1.6\nCRAWL_MIN_FRAMES = 10\nTAILGATE_MIN_FRAMES = 5\nGATE_OVERLAP_THRESH = 0.25\nTAILGATE_MAX_DIST = 200\nID_REMAP_FRAME_THRESH = 15\n\n# Fix3: 모든 파라미터를 CONFIG_DICT_V2에 반영\nCONFIG_DICT_V2 = dict(CONFIG_DICT)\nCONFIG_DICT_V2['crawl_aspect_ratio_thresh'] = CRAWL_AR_THRESH\nCONFIG_DICT_V2['crawl_min_frames'] = CRAWL_MIN_FRAMES\nCONFIG_DICT_V2['jump_min_frames'] = JUMP_MIN_FRAMES\nCONFIG_DICT_V2['tailgating_min_frames'] = TAILGATE_MIN_FRAMES\nCONFIG_DICT_V2['gate_overlap_thresh'] = GATE_OVERLAP_THRESH\nCONFIG_DICT_V2['tailgate_max_dist'] = float(TAILGATE_MAX_DIST)\ncfg_v2 = GateZoneConfig.from_dict(CONFIG_DICT_V2)\n\nprint('[CONFIG_V2] 적용값 확인')\nfor k in ['crawl_aspect_ratio_thresh','crawl_min_frames','jump_min_frames',\n 'tailgating_min_frames','gate_overlap_thresh','tailgate_max_dist']:\n print(f' {k:30s} = {getattr(cfg_v2, k)}')\n\n# ── 전체 재실행 ───────────────────────────────────────────────────\nall_candidates_v2 = []\nvideo_event_map_v2 = {}\n\nfor vp in tqdm_nb(target_videos, desc='재실행'):\n cands_i, _, _ = run_pipeline( # Fix2: 3-tuple + 모든 파라미터 전달\n vp, cfg_v2,\n output_dir=OUTPUT_DIR + '/v2',\n conf=CONF_THRESHOLD,\n gate_conf=GATE_CONF,\n near_gate_margin=NEAR_GATE_MARGIN,\n jump_multiplier=JUMP_MULTIPLIER,\n jump_min_score=JUMP_MIN_SCORE,\n id_remap_frame_thresh=ID_REMAP_FRAME_THRESH,\n )\n all_candidates_v2.extend(cands_i)\n video_event_map_v2[os.path.basename(vp)] = {c.event_type for c in cands_i}\n\n# ── Before / After 비교 ───────────────────────────────────────────\ndef score_map(vmap, target_videos, event_classes=EVENT_CLASSES):\n tp = tn = fp = fn = 0\n for vp in target_videos:\n gt = get_gt_from_path(vp) # Fix1: 폴더명 기준\n det = vmap.get(os.path.basename(vp), set())\n if gt == 'normal':\n if not det: tn += 1\n else: fp += 1\n else:\n if gt in det: tp += 1\n else: fn += 1\n total = tp + tn + fp + fn\n acc = round((tp+tn)/total*100, 1) if total else 0\n recall = round(tp/(tp+fn)*100, 1) if (tp+fn) else 0\n precision = round(tp/(tp+fp)*100, 1) if (tp+fp) else 0\n return dict(TP=tp, TN=tn, FP=fp, FN=fn, Acc=f'{acc}%',\n Recall=f'{recall}%', Precision=f'{precision}%')\n\nbefore = score_map(video_event_map, target_videos)\nafter = score_map(video_event_map_v2, target_videos)\n\nprint(f'{\"\":15s} {\"Before\":>10} {\"After\":>10}')\nprint('-' * 37)\nfor k in ['TP','TN','FP','FN','Acc','Recall','Precision']:\n b, a = str(before[k]), str(after[k])\n try:\n bv = float(b.rstrip('%')); av = float(a.rstrip('%'))\n better_high = k in ('TP','TN','Acc','Recall','Precision')\n mark = ' ✅' if (better_high and av>bv) else ' ❌' if (better_high and avbv) else ''\n except: mark = ''\n print(f'{k:15s} {b:>10} {a:>10}{mark}')\n\n# ── 결과 저장 ─────────────────────────────────────────────────────\nv2_settings = {\n 'conf_threshold' : CONF_THRESHOLD,\n 'gate_conf' : GATE_CONF,\n 'near_gate_margin' : NEAR_GATE_MARGIN,\n 'jump_multiplier' : JUMP_MULTIPLIER,\n 'jump_min_score' : JUMP_MIN_SCORE,\n 'jump_min_frames' : JUMP_MIN_FRAMES,\n 'crawl_ar_thresh' : CRAWL_AR_THRESH,\n 'crawl_min_frames' : CRAWL_MIN_FRAMES,\n 'tailgate_min_frames' : TAILGATE_MIN_FRAMES,\n 'gate_overlap_thresh' : GATE_OVERLAP_THRESH,\n 'tailgate_max_dist' : TAILGATE_MAX_DIST,\n 'id_remap_frame_thresh' : ID_REMAP_FRAME_THRESH,\n}\nsave_run_summary(\n label='tuned_v2',\n candidates=all_candidates_v2,\n vmap=video_event_map_v2,\n target_videos=target_videos,\n settings=v2_settings,\n out_dir=OUTPUT_DIR + '/v2',\n)\nimport shutil\nos.makedirs(DRIVE_SAVE, exist_ok=True)\nshutil.copy(OUTPUT_DIR + '/v2/run_log.json', f'{DRIVE_SAVE}/run_log_tuned.json')\nprint(f'Drive 저장 → {DRIVE_SAVE}/run_log_tuned.json')\n" + ], + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "cell-clips", + "metadata": {}, + "source": [ + "import glob\n", + "from IPython.display import Video, display\n", + "\n", + "clips = sorted(glob.glob(f'{OUTPUT_DIR}/clips/*.mp4'))\n", + "print(f'클립 {len(clips)}개')\n", + "for p in clips[:3]:\n", + " print(os.path.basename(p))\n", + " display(Video(p, embed=True, width=480))" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-save", + "metadata": {}, + "source": [ + "## 10. Drive 저장" + ] + }, + { + "cell_type": "code", + "id": "cell-save", + "metadata": {}, + "source": [ + "if os.path.exists(DRIVE_SAVE):\n", + " shutil.rmtree(DRIVE_SAVE)\n", + "shutil.copytree(OUTPUT_DIR, DRIVE_SAVE)\n", + "print(f'Drive 저장 완료 → {DRIVE_SAVE}')" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "md-tuning", + "metadata": {}, + "source": "## 11. 튜닝 가이드\n\n### jump 안 잡힐 때\n- 섹션 8-A의 score 값 확인\n- score < 0.30이면: `jump_ratio=0.20` 또는 `dynamic_multiplier=2.0` 낮추기\n\n### crawling 안 잡힐 때\n- 섹션 8-B 실행해서 `in_crawl`, `ar` 값 확인\n- `in_crawl=False`: gate 탐지 후 crawl_zone이 실제 영상 위치와 다름 → 섹션 5 미리보기에서 CRAWL(auto) 박스 위치 확인\n- `ar` 값이 1.5보다 크면: `crawl_aspect_ratio_thresh=2.0`으로 올리기\n- `crawl_min_frames=5`로 낮춰보기\n\n### tailgating 오탐(FP) 많을 때 — ID switch로 인한 오탐\n- `min_cooccupy_frames` 올리기 (기본 8 → 12~15)\n - ID switch는 ByteTrack이 보통 1~3프레임 안에 재할당하므로 streak이 길면 걸러짐\n\n### tailgating 미탐(FN) 많을 때\n- `min_cooccupy_frames` 내리기 (8 → 4~5)\n - gate 통과 시간이 짧아 streak을 못 채울 경우\n\n### gate 위치 필터가 너무 좁을 때 (jump FN)\n- `near_gate_x()` `margin_ratio=0.2` → `0.4`로 올리기\n\n조정 후 섹션 4-D(Rules) → 섹션 6 → 섹션 7 재실행" + } + ] +} \ No newline at end of file diff --git a/ai/detectors.py b/ai/detectors.py new file mode 100644 index 0000000..7d922d5 --- /dev/null +++ b/ai/detectors.py @@ -0,0 +1,165 @@ +from __future__ import annotations +import os +from typing import Callable, Optional, List + +import cv2 +import numpy as np +import torch +import torch.nn as nn +from torchvision import models, transforms + +from ai.types import Detection + + +class YOLODetector: + def __init__(self, model, conf_threshold: float = 0.4): + self.model = model + self.conf_threshold = conf_threshold + + def detect_persons(self, frame: np.ndarray) -> List[Detection]: + results = self.model(frame, classes=[0], conf=self.conf_threshold, verbose=False) + out = [] + for r in results: + if r.boxes is None: + continue + for box in r.boxes: + x1, y1, x2, y2 = box.xyxy[0].tolist() + out.append(Detection(x1=x1, y1=y1, x2=x2, y2=y2, confidence=float(box.conf[0]))) + return out + + +class GateDetector: + def __init__(self, model=None, conf_thres: float = 0.25): + self.model = model + self.conf_thres = conf_thres + + @property + def enabled(self) -> bool: + return self.model is not None + + def detect_best(self, frame: np.ndarray) -> Optional[dict]: + if frame is None or self.model is None: + return None + h, w = frame.shape[:2] + results = self.model.predict(source=frame, conf=self.conf_thres, verbose=False) + dets = [] + for result in results: + if result.boxes is None: + continue + for box in result.boxes: + x1, y1, x2, y2 = box.xyxy[0].tolist() + dets.append({ + 'x1': max(0, min(int(x1), w - 1)), + 'y1': max(0, min(int(y1), h - 1)), + 'x2': max(0, min(int(x2), w - 1)), + 'y2': max(0, min(int(y2), h - 1)), + 'conf': round(float(box.conf[0]), 4), + }) + return max(dets, key=lambda d: d['conf']) if dets else None + + +def make_blur_faces(face_model) -> Callable: + """얼굴 모델을 바인딩한 blur_faces 함수 반환. 모델이 None이면 원본 반환.""" + def blur_faces(frame: np.ndarray) -> np.ndarray: + if face_model is None: + return frame + out = frame.copy() + for r in face_model(frame, verbose=False): + if r.boxes is None: + continue + for box in r.boxes: + x1, y1, x2, y2 = map(int, box.xyxy[0].tolist()) + roi = out[y1:y2, x1:x2] + if roi.size: + out[y1:y2, x1:x2] = cv2.GaussianBlur(roi, (51, 51), 0) + return out + return blur_faces + + +# ── EfficientNet 2차 검증 ──────────────────────────────────────────────────── +EFF_CLASSES = ("jump", "crawling", "tailgating", "unpaid", "normal") +EFF_NUM_FRAMES = 16 + + +class _EfficientNetClipClassifier(nn.Module): + def __init__(self, num_classes: int = 5): + super().__init__() + backbone = models.efficientnet_b0(weights=None) + self.features = backbone.features + self.avgpool = backbone.avgpool + self.dropout = nn.Dropout(p=0.3) + self.fc = nn.Linear(1280, num_classes) + + def forward(self, clips): + b, t = clips.shape[:2] + x = clips.view(b * t, *clips.shape[2:]) + x = self.features(x) + x = self.avgpool(x).flatten(1) + x = x.view(b, t, 1280).mean(dim=1) + x = self.dropout(x) + return self.fc(x) + + +_eff_transform = transforms.Compose([ + transforms.ToPILImage(), + transforms.Resize(256), + transforms.CenterCrop(224), + transforms.ToTensor(), + transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), +]) + + +class EfficientNetVerifier: + """클립 영상 → 4+1 클래스 분류 (normal이면 FP로 간주).""" + + def __init__(self, checkpoint_path: Optional[str] = None, + conf_threshold: float = 0.50): + self.conf_threshold = conf_threshold + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self.model: Optional[_EfficientNetClipClassifier] = None + + if checkpoint_path and os.path.exists(checkpoint_path): + self.model = _EfficientNetClipClassifier(num_classes=len(EFF_CLASSES)).to(self.device) + ckpt = torch.load(checkpoint_path, map_location=self.device) + self.model.load_state_dict(ckpt["model_state"]) + self.model.eval() + print(f"[EfficientNet] 로드 완료: {checkpoint_path}") + else: + print(f"[EfficientNet] 체크포인트 없음 → 2차 검증 비활성화") + + @property + def enabled(self) -> bool: + return self.model is not None + + def verify(self, clip_path: Optional[str]) -> Optional[dict]: + """클립 경로 → 분류 결과 dict. 모델 없거나 clip_path=None이면 None.""" + if self.model is None or clip_path is None: + return None + cap = cv2.VideoCapture(clip_path) + total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + if total <= 0: + cap.release() + return None + idxs = np.linspace(0, max(total - 1, 0), EFF_NUM_FRAMES).astype(int) + frames = [] + for idx in idxs: + cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx)) + ok, f = cap.read() + if ok and f is not None: + frames.append(cv2.cvtColor(f, cv2.COLOR_BGR2RGB)) + cap.release() + if not frames: + return None + while len(frames) < EFF_NUM_FRAMES: + frames.append(frames[-1]) + tensors = [_eff_transform(f) for f in frames[:EFF_NUM_FRAMES]] + clip = torch.stack(tensors, dim=0).unsqueeze(0).to(self.device) + with torch.no_grad(): + probs = torch.softmax(self.model(clip), dim=1).squeeze(0).cpu().tolist() + pred_idx = int(max(range(len(EFF_CLASSES)), key=lambda i: probs[i])) + confidence = probs[pred_idx] + return { + "prediction": EFF_CLASSES[pred_idx] if confidence >= self.conf_threshold else "unknown", + "confidence": round(confidence, 4), + "probs": {c: round(probs[i], 4) for i, c in enumerate(EFF_CLASSES)}, + } diff --git a/ai/gateguard_colab.ipynb b/ai/gateguard_colab.ipynb new file mode 100644 index 0000000..9254e25 --- /dev/null +++ b/ai/gateguard_colab.ipynb @@ -0,0 +1,343 @@ +{ + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "colab": { + "name": "GateGuard AI — Colab", + "provenance": [], + "gpuType": "T4" + }, + "accelerator": "GPU" + }, + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": [ + "# 🛡️ GateGuard AI — Google Colab 추론 노트북\n", + "\n", + "**동작 순서**\n", + "1. 패키지 설치\n", + "2. 구글 드라이브 마운트\n", + "3. 설정 (영상 경로 · 라인 좌표 등)\n", + "4. 추론 실행 → 결과 영상 저장\n", + "\n", + "> GPU 런타임 권장: 런타임 → 런타임 유형 변경 → T4 GPU" + ] + }, + { + "cell_type": "markdown", + "id": "b2c3d4e5", + "metadata": {}, + "source": [ + "## 1️⃣ 패키지 설치" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3d4e5f6", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install -q ultralytics==8.4.31 supervision==0.27.0.post2 opencv-python-headless==4.13.0.92 httpx==0.28.1\n", + "print('✅ 패키지 설치 완료')" + ] + }, + { + "cell_type": "markdown", + "id": "d4e5f6a7", + "metadata": {}, + "source": [ + "## 2️⃣ 구글 드라이브 마운트" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5f6a7b8", + "metadata": {}, + "outputs": [], + "source": [ + "from google.colab import drive\n", + "drive.mount('/content/drive')\n", + "print('✅ 드라이브 마운트 완료')" + ] + }, + { + "cell_type": "markdown", + "id": "f6a7b8c9", + "metadata": {}, + "source": [ + "## 3️⃣ 설정\n", + "\n", + "아래 경로와 파라미터를 본인 환경에 맞게 수정하세요." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7b8c9d0", + "metadata": {}, + "outputs": [], + "source": "import os, glob\n\n# ──────────────────────────────────────────────\n# 드라이브 폴더 경로\n# ──────────────────────────────────────────────\nBASE_DIR = '/content/drive/MyDrive/ㅈㅎㅊ'\nCONVERT_DIR = f'{BASE_DIR}/변환 완료'\nOUTPUT_DIR = f'{BASE_DIR}/output'\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\n# 사용할 카테고리 (emergencydoor 제외)\nUSE_CATEGORIES = ['unpaid', 'tailgating', 'nomal', 'jump', 'crawling']\n\n# 해당 폴더들에서 영상 파일 수집\nvideo_files = []\nfor cat in USE_CATEGORIES:\n cat_dir = f'{CONVERT_DIR}/{cat}'\n files = sorted(\n glob.glob(f'{cat_dir}/**/*.mp4', recursive=True) +\n glob.glob(f'{cat_dir}/**/*.avi', recursive=True)\n )\n video_files.extend(files)\n print(f' {cat}: {len(files)}개')\n\nprint(f'\\n📂 총 영상 파일: {len(video_files)}개')\nfor i, p in enumerate(video_files):\n print(f' [{i:>3}] {os.path.relpath(p, CONVERT_DIR)}')\n\n# ──────────────────────────────────────────────\n# 분석할 영상 선택 (위 목록에서 인덱스 지정)\n# 전체 처리하려면 아래 루프 셀에서 video_files 전체 순회\n# ──────────────────────────────────────────────\nVIDEO_INDEX = 0 # ← 단일 영상 테스트 시 변경\nVIDEO_PATH = video_files[VIDEO_INDEX]\nOUTPUT_PATH = f'{OUTPUT_DIR}/{os.path.splitext(os.path.basename(VIDEO_PATH))[0]}_result.mp4'\nprint(f'\\n▶ 선택된 영상: {VIDEO_PATH}')\nprint(f'▶ 결과 저장: {OUTPUT_PATH}')\n\n# ──────────────────────────────────────────────\n# 감지 라인 좌표 (영상 해상도에 맞게 조정 필요)\n# ──────────────────────────────────────────────\nLINE_START = (0, 360)\nLINE_END = (640, 360)\n\n# ──────────────────────────────────────────────\n# 추론 파라미터\n# ──────────────────────────────────────────────\nCONFIDENCE_THRESHOLD = 0.5\nMAX_PREVIEW_FRAMES = 200 # None = 전체 처리\n\nREPORT_TO_BACKEND = False\nBACKEND_URL = 'http://localhost:8000'\nSECRET_KEY = 'gateguard-secret-key-dev'\n\nprint('\\n✅ 설정 완료')" + }, + { + "cell_type": "markdown", + "id": "b8c9d0e1", + "metadata": {}, + "source": [ + "## 4️⃣ 모듈 정의" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c9d0e1f2", + "metadata": {}, + "outputs": [], + "source": [ + "import cv2\n", + "import numpy as np\n", + "import supervision as sv\n", + "from ultralytics import YOLO\n", + "from IPython.display import display, clear_output\n", + "from PIL import Image\n", + "import io\n", + "\n", + "\n", + "# ─── 얼굴 비식별화 ───────────────────────────────\n", + "class FaceAnonymizer:\n", + " def __init__(self, model_path: str = 'yolov8n-face.pt'):\n", + " try:\n", + " self.model = YOLO(model_path)\n", + " self._enabled = True\n", + " print(f'✅ 얼굴 비식별화 모델 로드: {model_path}')\n", + " except Exception as e:\n", + " print(f'⚠️ 얼굴 비식별화 모델 로드 실패 ({e}) — 비활성화')\n", + " self.model = None\n", + " self._enabled = False\n", + "\n", + " def blur(self, frame: np.ndarray) -> np.ndarray:\n", + " if not self._enabled:\n", + " return frame\n", + " results = self.model(frame, verbose=False)[0]\n", + " for box in results.boxes.xyxy.cpu().numpy().astype(int):\n", + " x1, y1, x2, y2 = box\n", + " roi = frame[y1:y2, x1:x2]\n", + " if roi.size == 0:\n", + " continue\n", + " frame[y1:y2, x1:x2] = cv2.GaussianBlur(roi, (51, 51), 0)\n", + " return frame\n", + "\n", + "\n", + "# ─── 다중 객체 추적 ───────────────────────────────\n", + "class PersonTracker:\n", + " def __init__(self):\n", + " self.tracker = sv.ByteTrack()\n", + " self.annotator = sv.BoxAnnotator()\n", + " self.label_annotator = sv.LabelAnnotator()\n", + "\n", + " def update(self, detections: sv.Detections) -> sv.Detections:\n", + " return self.tracker.update_with_detections(detections)\n", + "\n", + " def annotate(self, frame: np.ndarray, detections: sv.Detections) -> np.ndarray:\n", + " labels = [f'ID:{tid}' for tid in (detections.tracker_id or [])]\n", + " annotated = self.annotator.annotate(frame.copy(), detections=detections)\n", + " return self.label_annotator.annotate(annotated, detections=detections, labels=labels)\n", + "\n", + "\n", + "# ─── Colab 미리보기 유틸 ─────────────────────────\n", + "def show_frame(frame_bgr: np.ndarray):\n", + " \"\"\"BGR 프레임을 Colab 셀에 인라인 출력\"\"\"\n", + " rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)\n", + " img = Image.fromarray(rgb)\n", + " buf = io.BytesIO()\n", + " img.save(buf, format='JPEG', quality=80)\n", + " buf.seek(0)\n", + " clear_output(wait=True)\n", + " display(Image.open(buf))\n", + "\n", + "\n", + "print('✅ 모듈 정의 완료')" + ] + }, + { + "cell_type": "markdown", + "id": "d0e1f2a3", + "metadata": {}, + "source": [ + "## 5️⃣ 추론 실행" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1f2a3b4", + "metadata": {}, + "outputs": [], + "source": [ + "import os, datetime\n", + "\n", + "# ─── 모델 로드 ────────────────────────────────────\n", + "person_model = YOLO('yolo11n.pt') # 사람 감지\n", + "anonymizer = FaceAnonymizer() # 얼굴 비식별화\n", + "tracker = PersonTracker() # 추적\n", + "\n", + "line_zone = sv.LineZone(\n", + " start=sv.Point(*LINE_START),\n", + " end=sv.Point(*LINE_END),\n", + ")\n", + "line_annotator = sv.LineZoneAnnotator()\n", + "triggered_ids = set()\n", + "event_log = [] # (frame_idx, track_id, confidence)\n", + "\n", + "# ─── 영상 열기 ────────────────────────────────────\n", + "cap = cv2.VideoCapture(VIDEO_PATH)\n", + "if not cap.isOpened():\n", + " raise FileNotFoundError(f'영상을 열 수 없습니다: {VIDEO_PATH}')\n", + "\n", + "fps = cap.get(cv2.CAP_PROP_FPS) or 30\n", + "width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))\n", + "height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))\n", + "total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))\n", + "print(f'📹 영상 정보: {width}×{height} @ {fps:.1f}fps, 총 {total}프레임')\n", + "\n", + "# ─── 출력 영상 준비 ───────────────────────────────\n", + "os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)\n", + "fourcc = cv2.VideoWriter_fourcc(*'mp4v')\n", + "writer = cv2.VideoWriter(OUTPUT_PATH, fourcc, fps, (width, height))\n", + "\n", + "# ─── 프레임 루프 ─────────────────────────────────\n", + "frame_idx = 0\n", + "preview_every = max(1, total // 20) # ~20장 미리보기\n", + "\n", + "print('🚀 추론 시작...')\n", + "while cap.isOpened():\n", + " ret, frame = cap.read()\n", + " if not ret:\n", + " break\n", + " if MAX_PREVIEW_FRAMES and frame_idx >= MAX_PREVIEW_FRAMES:\n", + " break\n", + "\n", + " # 1. 얼굴 비식별화\n", + " clean = anonymizer.blur(frame.copy())\n", + "\n", + " # 2. 사람 감지\n", + " results = person_model(clean, verbose=False)\n", + " detections = sv.Detections.from_ultralytics(results[0])\n", + " detections = detections[\n", + " (detections.class_id == 0) &\n", + " (detections.confidence >= CONFIDENCE_THRESHOLD)\n", + " ]\n", + "\n", + " # 3. 추적\n", + " detections = tracker.update(detections)\n", + "\n", + " # 4. 라인 크로싱 감지\n", + " crossed_in, crossed_out = line_zone.trigger(detections)\n", + " for i, (in_, out_) in enumerate(zip(crossed_in, crossed_out)):\n", + " if in_ or out_:\n", + " tid = int(detections.tracker_id[i]) if detections.tracker_id is not None else -1\n", + " conf = float(detections.confidence[i]) if detections.confidence is not None else 0.0\n", + " if tid not in triggered_ids:\n", + " triggered_ids.add(tid)\n", + " event_log.append((frame_idx, tid, round(conf, 3)))\n", + " print(f' ⚠️ [프레임 {frame_idx:05d}] 라인 크로싱 감지 — Track #{tid} conf={conf:.3f}')\n", + "\n", + " # 5. 어노테이션\n", + " annotated = tracker.annotate(clean, detections)\n", + " annotated = line_annotator.annotate(annotated, line_zone)\n", + "\n", + " # 6. 결과 저장\n", + " writer.write(annotated)\n", + "\n", + " # 7. 미리보기\n", + " if frame_idx % preview_every == 0:\n", + " show_frame(annotated)\n", + " print(f' 📸 프레임 {frame_idx}/{MAX_PREVIEW_FRAMES or total}')\n", + "\n", + " frame_idx += 1\n", + "\n", + "cap.release()\n", + "writer.release()\n", + "\n", + "print(f'\\n✅ 추론 완료 — 총 {frame_idx}프레임 처리')\n", + "print(f'📁 결과 저장: {OUTPUT_PATH}')\n", + "print(f'🚨 감지된 이벤트: {len(event_log)}건')" + ] + }, + { + "cell_type": "markdown", + "id": "f2a3b4c5", + "metadata": {}, + "source": [ + "## 6️⃣ 이벤트 요약" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3b4c5d6", + "metadata": {}, + "outputs": [], + "source": [ + "if event_log:\n", + " print(f'{'프레임':>8} {'Track ID':>10} {'신뢰도':>8}')\n", + " print('-' * 34)\n", + " for f_idx, tid, conf in event_log:\n", + " print(f'{f_idx:>8} {tid:>10} {conf:>8.3f}')\n", + "else:\n", + " print('감지된 라인 크로싱 이벤트 없음')" + ] + }, + { + "cell_type": "markdown", + "id": "b4c5d6e7", + "metadata": {}, + "source": [ + "## 7️⃣ 결과 영상 미리보기 (선택)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5d6e7f8", + "metadata": {}, + "outputs": [], + "source": [ + "# 저장된 결과 영상의 첫 50프레임을 GIF로 미리보기\n", + "from IPython.display import HTML\n", + "import base64\n", + "\n", + "cap2 = cv2.VideoCapture(OUTPUT_PATH)\n", + "frames = []\n", + "while len(frames) < 50:\n", + " ret, f = cap2.read()\n", + " if not ret: break\n", + " frames.append(Image.fromarray(cv2.cvtColor(f, cv2.COLOR_BGR2RGB)))\n", + "cap2.release()\n", + "\n", + "if frames:\n", + " gif_buf = io.BytesIO()\n", + " frames[0].save(\n", + " gif_buf, format='GIF', save_all=True,\n", + " append_images=frames[1:], loop=0, duration=60\n", + " )\n", + " b64 = base64.b64encode(gif_buf.getvalue()).decode()\n", + " display(HTML(f''))\n", + " print(f'\\n총 {len(frames)}프레임 미리보기')\n", + "else:\n", + " print('미리볼 프레임이 없습니다.')" + ] + } + ] +} \ No newline at end of file diff --git a/ai/gateguard_train.ipynb b/ai/gateguard_train.ipynb new file mode 100644 index 0000000..3683d49 --- /dev/null +++ b/ai/gateguard_train.ipynb @@ -0,0 +1,340 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": ["# GateGuard — YOLO person/child detector 학습\n", "**실행 전**: 런타임 → 런타임 유형 변경 → **T4 GPU** 선택"] + }, + { + "cell_type": "markdown", + "id": "cell-1", + "metadata": {}, + "source": ["## 1. 환경 설정"] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-2", + "metadata": {}, + "outputs": [], + "source": ["!pip install -q ultralytics pyyaml"] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "from google.colab import drive\n", + "drive.mount('/content/drive')\n", + "print('Drive 마운트 완료')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-4", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import re\n", + "\n", + "BASE = Path('/content/drive/MyDrive/ㅈㅎㅊ')\n", + "JSON_DIR = BASE / 'json'\n", + "ZIP_DIR = BASE / '지하철 역사 내 CCTV 이상행동 영상' / 'Training' / '개집표기 무단진입'\n", + "YOLO_DATASET_DIR = Path('/content/yolo_dataset')\n", + "RUNS_DIR = Path('/content/runs')\n", + "DRIVE_SAVE_DIR = Path('/content/drive/MyDrive/GateGuard_runs')\n", + "\n", + "CLASS_MAP = {'person': 0, 'child': 1}\n", + "CLASS_NAMES = ['person', 'child']\n", + "\n", + "print(f'JSON 폴더 존재 : {JSON_DIR.exists()}')\n", + "print(f'ZIP 폴더 존재 : {ZIP_DIR.exists()}')\n", + "print()\n", + "print('JSON 그룹 목록:')\n", + "for p in sorted(JSON_DIR.iterdir()):\n", + " print(f' {p.name} ({len(list(p.glob(\"annotation_*.json\")))}개 JSON)')\n", + "print()\n", + "print('ZIP 파일 목록:')\n", + "for p in sorted(ZIP_DIR.iterdir()):\n", + " print(f' {p.name}')" + ] + }, + { + "cell_type": "markdown", + "id": "cell-7", + "metadata": {}, + "source": ["## 2. YOLO 데이터셋 빌드"] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-8", + "metadata": {}, + "outputs": [], + "source": [ + "import json, zipfile, yaml, random, re\n", + "from pathlib import Path\n", + "\n", + "def to_yolo_line(cid, x, y, w, h, iw, ih):\n", + " cx = max(0.0, min(1.0, (x + w/2) / iw))\n", + " cy = max(0.0, min(1.0, (y + h/2) / ih))\n", + " nw = max(0.0, min(1.0, w / iw))\n", + " nh = max(0.0, min(1.0, h / ih))\n", + " return f'{cid} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}'\n", + "\n", + "def parse_json(path):\n", + " try:\n", + " raw = json.loads(path.read_text(encoding='utf-8'))\n", + " except Exception as e:\n", + " print(f' [WARN] {path.name}: {e}')\n", + " return None\n", + " meta = raw.get('metadata', {})\n", + " frames = []\n", + " for fr in raw.get('frames', []):\n", + " boxes = []\n", + " for ann in fr.get('annotations', []):\n", + " code = ann['category']['code']\n", + " if code not in CLASS_MAP: continue\n", + " lb = ann['label']\n", + " if lb['width'] > 0 and lb['height'] > 0:\n", + " boxes.append({'cid': CLASS_MAP[code], **lb})\n", + " if boxes:\n", + " frames.append({\n", + " 'n': fr['number'],\n", + " 'img': fr.get('image', f'frame_{fr[\"number\"]}.jpg'),\n", + " 'boxes': boxes\n", + " })\n", + " return {'id': raw['id'], 'w': meta.get('width',3840), 'h': meta.get('height',2160), 'frames': frames}\n", + "\n", + "def get_group_num(name: str):\n", + " m = re.search(r'_(\\d+)\\.zip$', name)\n", + " return m.group(1) if m else None\n", + "\n", + "def build_dataset(val_ratio=0.2, seed=42):\n", + " random.seed(seed)\n", + " for split in ['train', 'val']:\n", + " (YOLO_DATASET_DIR/'images'/split).mkdir(parents=True, exist_ok=True)\n", + " (YOLO_DATASET_DIR/'labels'/split).mkdir(parents=True, exist_ok=True)\n", + "\n", + " # ZIP 파일 인덱스: grp → zip_path\n", + " zip_index = {}\n", + " for zp in sorted(ZIP_DIR.glob('*.zip')):\n", + " grp = get_group_num(zp.name)\n", + " if grp:\n", + " zip_index[grp] = zp\n", + "\n", + " all_videos = []\n", + " for label_dir in sorted(JSON_DIR.iterdir()):\n", + " if not label_dir.is_dir(): continue\n", + " m = re.search(r'_(\\d+)$', label_dir.name)\n", + " if not m: continue\n", + " grp = m.group(1)\n", + " if grp not in zip_index:\n", + " print(f' [SKIP] 그룹 {grp} ZIP 없음')\n", + " continue\n", + " for jp in sorted(label_dir.glob('annotation_*.json')):\n", + " v = parse_json(jp)\n", + " if v and v['frames']:\n", + " all_videos.append((v, zip_index[grp]))\n", + "\n", + " random.shuffle(all_videos)\n", + " val_n = max(1, int(len(all_videos) * val_ratio))\n", + " splits = {'val': all_videos[:val_n], 'train': all_videos[val_n:]}\n", + " print(f'총 영상: {len(all_videos)}개 → train:{len(splits[\"train\"])} val:{len(splits[\"val\"])}')\n", + "\n", + " imgs = {'train': 0, 'val': 0}\n", + " cnts = {'train': {0:0, 1:0}, 'val': {0:0, 1:0}}\n", + " skipped = 0\n", + "\n", + " for split, vid_list in splits.items():\n", + " img_out = YOLO_DATASET_DIR / 'images' / split\n", + " lbl_out = YOLO_DATASET_DIR / 'labels' / split\n", + "\n", + " # ZIP별로 묶어서 한 번씩만 열기\n", + " from collections import defaultdict\n", + " zip_groups = defaultdict(list)\n", + " for v, zp in vid_list:\n", + " zip_groups[zp].append(v)\n", + "\n", + " for zp, videos in zip_groups.items():\n", + " print(f' [{split}] {zp.name} 처리 중...')\n", + " with zipfile.ZipFile(zp) as zf:\n", + " zip_names = set(zf.namelist())\n", + " for v in videos:\n", + " for fr in v['frames']:\n", + " inner = f\"{v['id']}/{fr['img']}\"\n", + " if inner not in zip_names:\n", + " skipped += 1\n", + " continue\n", + " stem = f\"{v['id']}_{fr['n']:06d}\"\n", + " (img_out / f'{stem}.jpg').write_bytes(zf.read(inner))\n", + " lines = [to_yolo_line(b['cid'],b['x'],b['y'],b['width'],b['height'],v['w'],v['h'])\n", + " for b in fr['boxes']]\n", + " (lbl_out / f'{stem}.txt').write_text('\\n'.join(lines))\n", + " for b in fr['boxes']: cnts[split][b['cid']] += 1\n", + " imgs[split] += 1\n", + "\n", + " yaml_path = YOLO_DATASET_DIR / 'data.yaml'\n", + " with open(yaml_path, 'w') as f:\n", + " yaml.dump({'path': str(YOLO_DATASET_DIR), 'train': 'images/train', 'val': 'images/val',\n", + " 'nc': len(CLASS_NAMES), 'names': CLASS_NAMES}, f, allow_unicode=True, sort_keys=False)\n", + "\n", + " for s in ['train', 'val']:\n", + " print(f'[{s}] 이미지:{imgs[s]}장 person:{cnts[s][0]} child:{cnts[s][1]}')\n", + " if skipped: print(f'건너뜀: {skipped}')\n", + " print(f'\\n{yaml_path.read_text()}')\n", + "\n", + "build_dataset(val_ratio=0.2)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-9", + "metadata": {}, + "source": ["## 3. 학습"] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-10", + "metadata": {}, + "outputs": [], + "source": [ + "# 처음엔 QUICK_MODE=True → 이상없으면 False로 본 학습\n", + "QUICK_MODE = True\n", + "MODEL = 'yolo11n.pt'\n", + "\n", + "EPOCHS = 5 if QUICK_MODE else 30\n", + "IMGSZ = 416 if QUICK_MODE else 640\n", + "BATCH = 8 if QUICK_MODE else -1\n", + "CACHE = False if QUICK_MODE else True\n", + "RUN_NAME = 'gateguard_quick' if QUICK_MODE else 'gateguard_detector'\n", + "\n", + "train_n = len(list((YOLO_DATASET_DIR/'images'/'train').glob('*.jpg')))\n", + "val_n = len(list((YOLO_DATASET_DIR/'images'/'val').glob('*.jpg')))\n", + "assert train_n > 0, '❌ train 이미지 없음 — 셀 2 먼저 실행하세요'\n", + "print(f'train:{train_n}장 val:{val_n}장')\n", + "print(f'모델:{MODEL} epochs:{EPOCHS} imgsz:{IMGSZ} batch:{BATCH}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "from ultralytics import YOLO\n", + "\n", + "model = YOLO(MODEL)\n", + "model.train(\n", + " data=str(YOLO_DATASET_DIR / 'data.yaml'),\n", + " epochs=EPOCHS, imgsz=IMGSZ, batch=BATCH, cache=CACHE,\n", + " project=str(RUNS_DIR), name=RUN_NAME, exist_ok=True,\n", + ")\n", + "best_pt = RUNS_DIR / RUN_NAME / 'weights' / 'best.pt'\n", + "print(f'학습 완료: {best_pt}')" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": ["## 4. 결과 확인"] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-13", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt, matplotlib.image as mpimg\n", + "\n", + "run_dir = RUNS_DIR / RUN_NAME\n", + "for name in ['results.png', 'confusion_matrix.png']:\n", + " p = run_dir / name\n", + " if p.exists():\n", + " fig, ax = plt.subplots(figsize=(12,5))\n", + " ax.imshow(mpimg.imread(p)); ax.axis('off'); ax.set_title(name)\n", + " plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-14", + "metadata": {}, + "outputs": [], + "source": [ + "best_model = YOLO(str(best_pt))\n", + "metrics = best_model.val(data=str(YOLO_DATASET_DIR / 'data.yaml'))\n", + "print(f'mAP50 : {metrics.box.map50:.4f}')\n", + "print(f'mAP50-95 : {metrics.box.map:.4f}')\n", + "for i, name in enumerate(CLASS_NAMES):\n", + " if i < len(metrics.box.ap50):\n", + " print(f' {name} AP50: {metrics.box.ap50[i]:.4f}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-15", + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "from PIL import Image\n", + "\n", + "val_imgs = list((YOLO_DATASET_DIR/'images'/'val').glob('*.jpg'))\n", + "if val_imgs:\n", + " sample = random.choice(val_imgs)\n", + " r = best_model.predict(str(sample), conf=0.3, verbose=False)[0]\n", + " fig, axes = plt.subplots(1,2,figsize=(16,6))\n", + " axes[0].imshow(Image.open(sample)); axes[0].set_title('원본'); axes[0].axis('off')\n", + " axes[1].imshow(r.plot()[:,:,::-1]); axes[1].set_title(f'탐지 {len(r.boxes)}건'); axes[1].axis('off')\n", + " plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-16", + "metadata": {}, + "source": ["## 5. Drive에 결과 저장"] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-17", + "metadata": {}, + "outputs": [], + "source": [ + "import shutil\n", + "DRIVE_SAVE_DIR.mkdir(parents=True, exist_ok=True)\n", + "dest = DRIVE_SAVE_DIR / RUN_NAME\n", + "if dest.exists(): shutil.rmtree(dest)\n", + "shutil.copytree(run_dir, dest)\n", + "print(f'저장 완료: {dest}')\n", + "print(f'best.pt : {dest / \"weights\" / \"best.pt\"}')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ai/inference.py b/ai/inference.py index ac280ea..5202c80 100644 --- a/ai/inference.py +++ b/ai/inference.py @@ -1,119 +1,167 @@ """ -GateGuard 메인 추론 파이프라인 +GateGuard AI 추론 엔트리포인트. +백엔드가 먼저 떠 있어야 동작합니다 (JWT 검증 대상). -흐름: - 영상 입력 → 비식별화 → YOLOv11 감지 → ByteTrack 추적 - → Line Crossing 판정 → 무임승차 이벤트 발생 → 백엔드 API 전송 +실행: + python -m ai.inference # 기본 카메라(0) + python -m ai.inference --source /path/to.mp4 # 영상 파일 + python -m ai.inference --source 0 --show # 화면 출력 """ +import argparse import asyncio -from dataclasses import dataclass, field +import datetime +import os -import cv2 import httpx -import numpy as np -import supervision as sv +from dotenv import load_dotenv +from jose import jwt from ultralytics import YOLO -from ai.anonymizer import FaceAnonymizer -from ai.tracker import PersonTracker - - -@dataclass -class GateConfig: - """개찰구 라인 설정""" - # 감지 경계선 (픽셀 좌표): 게이트를 가로지르는 선 - line_start: tuple[int, int] = (0, 360) - line_end: tuple[int, int] = (640, 360) - backend_url: str = "http://localhost:8000" - camera_id: int = 1 - confidence_threshold: float = 0.5 - - -@dataclass -class FareEvasionDetector: - config: GateConfig - _triggered_ids: set[int] = field(default_factory=set) - - def __post_init__(self): - self.model = YOLO("yolo11n.pt") - self.anonymizer = FaceAnonymizer() - self.tracker = PersonTracker() - self.line_zone = sv.LineZone( - start=sv.Point(*self.config.line_start), - end=sv.Point(*self.config.line_end), - ) - self.line_annotator = sv.LineZoneAnnotator() - - def _to_detections(self, results) -> sv.Detections: - detections = sv.Detections.from_ultralytics(results[0]) - # 사람(class 0)만 필터링 - mask = (detections.class_id == 0) & (detections.confidence >= self.config.confidence_threshold) - return detections[mask] - - async def _report_event(self, track_id: int, confidence: float): - payload = { - "camera_id": self.config.camera_id, - "track_id": track_id, - "confidence": round(float(confidence), 4), - } - async with httpx.AsyncClient() as client: - try: - await client.post(f"{self.config.backend_url}/api/events", json=payload, timeout=5) - except httpx.RequestError as e: - print(f"[WARNING] 이벤트 전송 실패: {e}") - - def process_frame(self, frame: np.ndarray) -> tuple[np.ndarray, list[int]]: - """ - 단일 프레임 처리. - 반환: (annotated_frame, 이번 프레임에서 새로 감지된 무임승차 track_id 목록) - """ - frame = self.anonymizer.blur(frame) - results = self.model(frame, verbose=False) - detections = self._to_detections(results) - detections = self.tracker.update(detections) - - crossed_in, crossed_out = self.line_zone.trigger(detections) - new_events: list[int] = [] - - for i, (in_, out_) in enumerate(zip(crossed_in, crossed_out)): - if not (in_ or out_): - continue - tid = detections.tracker_id[i] if detections.tracker_id is not None else -1 - if tid in self._triggered_ids: - continue - self._triggered_ids.add(tid) - new_events.append(tid) - conf = float(detections.confidence[i]) if detections.confidence is not None else 0.0 - asyncio.create_task(self._report_event(tid, conf)) - - annotated = self.tracker.annotate(frame, detections) - annotated = self.line_annotator.annotate(annotated, self.line_zone) - return annotated, new_events - - def run(self, source: int | str = 0): - """실시간 스트림 실행 (source: 카메라 인덱스 또는 RTSP URL)""" - cap = cv2.VideoCapture(source) - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - print(f"[GateGuard] 추론 시작 — 카메라 ID: {self.config.camera_id}") +from ai.detectors import EfficientNetVerifier, GateDetector, YOLODetector, make_blur_faces +from ai.pipeline import run_pipeline +from ai.types import EventCandidate, GateZoneConfig + +# ── 환경변수 ───────────────────────────────────────────────────────────────── +load_dotenv(os.path.join(os.path.dirname(__file__), '../backend/.env')) + +SECRET_KEY = os.getenv("SECRET_KEY", "gateguard-secret-key-dev") +ALGORITHM = os.getenv("ALGORITHM", "HS256") +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") +CAMERA_ID = int(os.getenv("CAMERA_ID", "1")) + +# ── 로컬 모델/출력 경로 ──────────────────────────────────────────────────── +_DIR = os.path.dirname(__file__) +PERSON_MODEL = os.path.join(_DIR, '../yolo11n.pt') +GATE_MODEL = os.path.join(_DIR, 'gate_best.pt') +FACE_MODEL = os.path.join(_DIR, 'yolov11n-face.pt') +EFF_CKPT = os.getenv("EFF_CHECKPOINT", "") # 없으면 2차 검증 비활성화 +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "pipeline_output") + +# ── 기본 설정 (카메라/해상도에 맞게 조정) ──────────────────────────────────── +CONFIG_DICT = { + 'camera_id': f'cam{CAMERA_ID}', + 'frame_width': 640, + 'frame_height': 480, + 'gate_zone': {'x1': 180, 'y1': 80, 'x2': 460, 'y2': 420}, + 'pass_line': {'x1': 180, 'y1': 280, 'x2': 460, 'y2': 280}, + 'jump_zone': {'x1': 160, 'y1': 40, 'x2': 480, 'y2': 180}, + 'crawl_zone': {'x1': 160, 'y1': 340, 'x2': 480, 'y2': 430}, + 'jump_min_frames': 3, + 'crawl_min_frames': 10, + 'crawl_aspect_ratio_thresh': 1.6, + 'tailgate_time_window_s': 3.0, + 'tailgate_distance_thresh': 150.0, + 'tailgating_min_frames': 5, + 'gate_overlap_thresh': 0.25, + 'tailgate_max_dist': 200, +} + + +def generate_master_token() -> str: + """AI 엔진이 백엔드에 인증하기 위한 JWT 발급""" + expire = datetime.datetime.utcnow() + datetime.timedelta(days=1) + to_encode = {"sub": "1", "email": "admin@gateguard.com", "exp": expire} + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +async def report_event(candidate: EventCandidate): + """감지된 이벤트를 백엔드에 POST""" + token = generate_master_token() + payload = { + "camera_id": CAMERA_ID, + "track_id": candidate.track_ids[0] if candidate.track_ids else -1, + "confidence": round(float(candidate.confidence), 3), + "clip_url": candidate.clip_path or "", + "event_type": candidate.event_type, + } + headers = {"Authorization": f"Bearer {token}"} + async with httpx.AsyncClient() as client: + try: + await client.post(f"{BACKEND_URL}/api/events/", json=payload, + headers=headers, timeout=10) + print(f" 🚀 [REPORTED] {candidate.event_type} track={candidate.track_ids}") + except Exception as e: + print(f" ⚠️ [REPORT FAILED] {e}") + + +def load_models(): + """YOLO + Face + Gate + EfficientNet 모델 로드""" + print("[GateGuard] 모델 로딩 중...") + person_model = YOLO(PERSON_MODEL) + print(f" Person YOLO: {PERSON_MODEL}") + + gate_model = None + if os.path.exists(GATE_MODEL): + gate_model = YOLO(GATE_MODEL) + print(f" Gate YOLO: {GATE_MODEL}") + else: + print(f" [WARN] gate_best.pt 없음 → gate 위치 필터 비활성화") + + face_model = None + if os.path.exists(FACE_MODEL): + face_model = YOLO(FACE_MODEL) + print(f" Face YOLO: {FACE_MODEL}") + else: try: - while cap.isOpened(): - ret, frame = cap.read() - if not ret: - break - annotated, events = self.process_frame(frame) - if events: - print(f"[ALERT] 무임승차 감지 — track_ids: {events}") - cv2.imshow("GateGuard", annotated) - if cv2.waitKey(1) & 0xFF == ord("q"): - break - finally: - cap.release() - cv2.destroyAllWindows() - loop.close() - - -if __name__ == "__main__": - detector = FareEvasionDetector(config=GateConfig()) - detector.run(source=0) + from huggingface_hub import hf_hub_download + face_pt = hf_hub_download( + 'arnabdhar/YOLOv8-Face-Detection', 'model.pt', + local_dir=_DIR) + face_model = YOLO(face_pt) + print(" Face YOLO: HuggingFace에서 로드") + except Exception as e: + print(f" [WARN] 얼굴 모델 로드 실패 → 블러 생략: {e}") + + verifier = EfficientNetVerifier(checkpoint_path=EFF_CKPT or None) + + return ( + YOLODetector(person_model, conf_threshold=0.4), + GateDetector(gate_model, conf_thres=0.25), + make_blur_faces(face_model), + verifier, + ) + + +def main(): + parser = argparse.ArgumentParser(description='GateGuard AI Inference') + parser.add_argument('--source', default='0', + help='카메라 인덱스(0) 또는 영상 파일 경로') + parser.add_argument('--show', action='store_true', + help='화면 출력 (cv2.imshow)') + parser.add_argument('--output-dir', default=OUTPUT_DIR) + parser.add_argument('--no-report', action='store_true', + help='백엔드 보고 생략 (테스트용)') + args = parser.parse_args() + + source = int(args.source) if args.source.isdigit() else args.source + + person_det, gate_det, blur_fn, verifier = load_models() + cfg = GateZoneConfig.from_dict(CONFIG_DICT) + + # 이벤트 발생 시 백엔드 보고 (asyncio 루프 활용) + loop = asyncio.new_event_loop() + + def on_event(c: EventCandidate): + if not args.no_report: + loop.run_until_complete(report_event(c)) + + print(f"\n💎 [GATE GUARD] AI INFERENCE LIVE — source={source}") + + try: + run_pipeline( + video_path=source, + cfg_base=cfg, + output_dir=args.output_dir, + person_detector=person_det, + gate_detector=gate_det, + blur_faces=blur_fn, + verifier=verifier, + on_event=on_event, + show=args.show, + ) + finally: + loop.close() + + +if __name__ == '__main__': + main() diff --git a/ai/pipeline.py b/ai/pipeline.py new file mode 100644 index 0000000..2ecfa74 --- /dev/null +++ b/ai/pipeline.py @@ -0,0 +1,335 @@ +from __future__ import annotations +import copy +import json +import os +from collections import deque, defaultdict +from pathlib import Path +from typing import Callable, List, Optional, Tuple + +import cv2 +import numpy as np +import supervision as sv +from tqdm import tqdm + +from ai.detectors import GateDetector, YOLODetector, EfficientNetVerifier, make_blur_faces +from ai.rules import CrawlingRule, IDRemapper, JumpDetector, TailgatingRule +from ai.types import (EventCandidate, GateZoneConfig, PassLine, TrackedPerson, + Zone, near_gate_x) + +GATE_CACHE_FRAMES = 60 + + +def _save_clip(c: EventCandidate, buf, out_dir: str, fps: float, + margin_s: float = 2.0) -> Optional[str]: + m = int(fps * margin_s) + frames = [f for fi, f in buf if c.start_frame - m <= fi <= c.end_frame + m] + if not frames: + return None + name = f"{c.event_type}_{c.start_frame}_{'_'.join(str(t) for t in c.track_ids)}.mp4" + path = str(Path(out_dir) / name) + h, w = frames[0].shape[:2] + writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*'mp4v'), fps, (w, h)) + for f in frames: + writer.write(f) + writer.release() + return path + + +def _update_cfg_from_gate(cfg: GateZoneConfig, gate: dict): + gx1, gy1, gx2, gy2 = gate['x1'], gate['y1'], gate['x2'], gate['y2'] + gh = gy2 - gy1 + cfg.pass_line = PassLine(gx1, (gy1 + gy2) // 2, gx2, (gy1 + gy2) // 2) + cfg.crawl_zone = Zone(gx1, int(gy1 + gh * 0.6), gx2, gy2) + cfg.gate_zone = Zone(gx1, gy1, gx2, gy2) + + +def _draw_overlay(frame: np.ndarray, cfg: GateZoneConfig, + persons: List[TrackedPerson], gate_bbox=None, + jump_scores=None, remapper=None): + z = cfg.crawl_zone + cv2.rectangle(frame, (z.x1, z.y1), (z.x2, z.y2), (180, 0, 0), 1) + cv2.putText(frame, 'CRAWL', (z.x1 + 2, z.y1 + 12), + cv2.FONT_HERSHEY_SIMPLEX, 0.38, (180, 0, 0), 1) + if gate_bbox: + gx1, gy1, gx2, gy2 = gate_bbox['x1'], gate_bbox['y1'], gate_bbox['x2'], gate_bbox['y2'] + cv2.rectangle(frame, (gx1, gy1), (gx2, gy2), (0, 220, 220), 2) + cv2.putText(frame, f"gate {gate_bbox['conf']:.2f}", (gx1, gy1 - 6), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 220, 220), 1, cv2.LINE_AA) + else: + cv2.putText(frame, 'gate: not found', (8, 48), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (80, 80, 200), 1) + for p in persons: + score = (jump_scores or {}).get(p.track_id, 0.0) + is_jumping = score >= 0.25 + color = (0, 0, 255) if is_jumping else (0, 200, 0) + cv2.rectangle(frame, (int(p.x1), int(p.y1)), (int(p.x2), int(p.y2)), + color, 2 if is_jumping else 1) + canonical = remapper.canonical(p.track_id) if remapper else p.track_id + id_label = f'{canonical}' if canonical != p.track_id else f'{p.track_id}' + label = f'ID:{id_label} J:{score:.2f}' if score > 0.05 else f'ID:{id_label}' + cv2.putText(frame, label, (int(p.x1), int(p.y1) - 4), + cv2.FONT_HERSHEY_SIMPLEX, 0.38, color, 1) + + +def run_pipeline( + video_path: str | int, + cfg_base: GateZoneConfig, + output_dir: str = 'pipeline_output', + person_detector: Optional[YOLODetector] = None, + gate_detector: Optional[GateDetector] = None, + blur_faces: Optional[Callable] = None, + verifier: Optional[EfficientNetVerifier] = None, + conf: float = 0.4, + gate_conf: float = 0.25, + near_gate_margin: float = 0.2, + jump_multiplier: float = 5.0, + jump_min_score: float = 0.25, + id_remap_frame_thresh: int = 15, + verbose_rules: bool = False, + max_events: Optional[int] = None, + on_event: Optional[Callable[[EventCandidate], None]] = None, + show: bool = False, +) -> Tuple[List[EventCandidate], list, dict]: + """ + 단일 영상(또는 카메라 소스)을 처리해 이벤트 후보 목록을 반환합니다. + + on_event : 이벤트 확정 시 호출되는 콜백 (백엔드 연동 등). 인자는 EventCandidate. + show : True면 cv2.imshow로 영상 출력. + """ + Path(output_dir + '/clips').mkdir(parents=True, exist_ok=True) + cfg = copy.deepcopy(cfg_base) + + # 모델이 전달되지 않으면 no-op으로 대체 + _blur = blur_faces if blur_faces is not None else (lambda f: f) + + tracker = sv.ByteTrack() + remapper = IDRemapper(dist_thresh=80, frame_thresh=id_remap_frame_thresh) + jump_detector = JumpDetector( + window_frames=30, jump_ratio=0.30, + calibration_frames=90, + dynamic_multiplier=jump_multiplier, + min_score=jump_min_score, + ) + jump_triggered: set = set() + jump_consec: dict = defaultdict(int) + + rules = { + 'crawling': CrawlingRule(cfg), + 'tailgating': TailgatingRule(cfg), + } + rules['tailgating'].debug = verbose_rules + rules['crawling'].debug = verbose_rules + + diag_info = { + 'gate_found_frames': 0, + 'total_frames': 0, + 'unique_tids': set(), + 'id_switches': 0, + 'jump_peak_scores': {}, + 'tail_max_streak': 0, + } + + cap = cv2.VideoCapture(video_path) + total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 + buf: deque = deque(maxlen=int(fps * 20)) + all_c: List[EventCandidate] = [] + ann_frames: list = [] + fi = 0 + cached_gate = None + gate_cache_age = 0 + gate_calibrated = False + prev_tids: set = set() + + src_name = os.path.basename(str(video_path)) if isinstance(video_path, str) else f'cam{video_path}' + + for _ in tqdm(range(total or 9999), desc=src_name, leave=False): + ret, frame = cap.read() + if not ret: + break + fi += 1 + buf.append((fi, frame.copy())) + + if max_events is not None and len(all_c) >= max_events: + print(f' [조기 종료] {max_events}건 달성 (frame={fi})') + break + + clean = _blur(frame.copy()) + + # person 탐지 + tracking + if person_detector is not None: + raw = person_detector.detect_persons(clean) + else: + raw = [] + + if raw: + xyxy = np.array([[d.x1, d.y1, d.x2, d.y2] for d in raw], dtype=float) + sv_d = sv.Detections(xyxy=xyxy, confidence=np.array([d.confidence for d in raw])) + else: + sv_d = sv.Detections.empty() + sv_d = tracker.update_with_detections(sv_d) + persons = ( + [TrackedPerson(int(sv_d.tracker_id[i]), *[float(v) for v in sv_d.xyxy[i]]) + for i in range(len(sv_d))] + if sv_d.tracker_id is not None else [] + ) + + # ID switch 보정 + remapper.record_positions(persons) + new_maps = remapper.update(fi, persons, prev_tids) + for new_tid, old_tid in new_maps.items(): + jump_detector.transfer_history(old_tid, new_tid) + if old_tid in jump_triggered: + jump_triggered.add(new_tid) + prev_tids = {p.track_id for p in persons} + + # gate bbox 탐지 + 캐시 + 좌표 자동 보정 + if gate_detector is not None and gate_detector.enabled: + new_gate = gate_detector.detect_best(clean) + if new_gate: + cached_gate = new_gate + gate_cache_age = 0 + diag_info['gate_found_frames'] += 1 + _update_cfg_from_gate(cfg, cached_gate) + gate_h = cached_gate['y2'] - cached_gate['y1'] + jump_detector.set_gate_height(gate_h) + if not gate_calibrated: + gate_calibrated = True + print(f'[{src_name}] gate 보정 → ' + f'y={cfg.pass_line.y1}, ' + f'jump_min={jump_detector._gate_min:.1f}px') + else: + gate_cache_age += 1 + if gate_cache_age > GATE_CACHE_FRAMES: + cached_gate = None + + # JumpDetector 업데이트 + active_tids: set = set() + if sv_d.tracker_id is not None: + for i, tid in enumerate(sv_d.tracker_id): + tid = int(tid) + active_tids.add(tid) + diag_info['unique_tids'].add(remapper.canonical(tid)) + jump_detector.update(tid, sv_d.xyxy[i]) + jump_detector.cleanup(active_tids) + jump_scores = {p.track_id: jump_detector.get_score(p.track_id) for p in persons} + + for p in persons: + canon = remapper.canonical(p.track_id) + sc = jump_scores.get(p.track_id, 0.0) + if sc > diag_info['jump_peak_scores'].get(canon, 0.0): + diag_info['jump_peak_scores'][canon] = sc + if rules['tailgating']._pair_streak: + diag_info['tail_max_streak'] = max( + diag_info['tail_max_streak'], + max(rules['tailgating']._pair_streak.values())) + + if verbose_rules and fi % 30 == 0 and persons: + for p in persons: + canon = remapper.canonical(p.track_id) + jsc = jump_scores.get(p.track_id, 0.0) + near_g = near_gate_x(p, cached_gate, 0.3) + consec = jump_consec.get(canon, 0) + in_trig = canon in jump_triggered + print(f' [VB] f={fi} ID={canon} ar={p.aspect_ratio:.2f} ' + f'near_gate={near_g} jump_sc={jsc:.2f} ' + f'consec={consec}/{cfg_base.jump_min_frames} triggered={in_trig}') + + cands: List[EventCandidate] = [] + + # ── jump (3프레임 연속 is_jump=True일 때만 트리거) ─────────────────── + for p in persons: + canonical = remapper.canonical(p.track_id) + if canonical in jump_triggered: + continue + if jump_detector.is_jump(p.track_id) and near_gate_x(p, cached_gate, near_gate_margin): + jump_consec[canonical] += 1 + else: + jump_consec[canonical] = 0 + if jump_consec[canonical] < cfg_base.jump_min_frames: + continue + jump_triggered.add(canonical) + score = jump_scores.get(p.track_id, 0.0) + gate_info = (f'gate_filter=ON conf={cached_gate["conf"]:.2f}' + if cached_gate else 'gate_filter=OFF') + cands.append(EventCandidate( + 'jump', fi, fi, [canonical], + min(1.0, score / 0.5), + f'Y(y2) score={score:.2f} consec=3 ({gate_info})')) + + # ── crawling / tailgating ──────────────────────────────────────────── + cands += rules['crawling'].update(fi, persons, cached_gate) + cands += rules['tailgating'].update(fi, persons, fps, cached_gate) + + for c in cands: + if max_events is not None and len(all_c) >= max_events: + break + clip_path = _save_clip(c, buf, output_dir + '/clips', fps) + c.clip_path = clip_path + + # EfficientNet 2차 검증 + if verifier is not None and verifier.enabled: + eff = verifier.verify(clip_path) + c.efficientnet_result = eff + if eff and eff['prediction'] == 'normal': + print(f' [EFF✗] {c.event_type} → normal({eff["confidence"]:.2f}) 제거 ' + + os.path.basename(clip_path or '')) + continue + + eff_tag = '' + if c.efficientnet_result: + e = c.efficientnet_result + eff_tag = f' eff={e["prediction"]}({e["confidence"]:.2f})' + + all_c.append(c) + print(f' [{c.event_type}] frame={fi} tracks={c.track_ids} ' + f'conf={c.confidence:.2f}{eff_tag} | {c.reason}') + + # 백엔드 콜백 + if on_event is not None: + on_event(c) + + if show: + vis = frame.copy() + _draw_overlay(vis, cfg, persons, gate_bbox=cached_gate, + jump_scores=jump_scores, remapper=remapper) + cv2.putText(vis, f'!! {c.event_type.upper()}', (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) + ann_frames.append((fi, c.event_type, vis.copy())) + + if show: + display = frame.copy() + _draw_overlay(display, cfg, persons, gate_bbox=cached_gate, + jump_scores=jump_scores, remapper=remapper) + cv2.imshow('GateGuard Pipeline', display) + if cv2.waitKey(1) & 0xFF == ord('q'): + break + + diag_info['total_frames'] = fi + diag_info['id_switches'] = len(remapper._remap) + + if verbose_rules: + gf = diag_info['gate_found_frames'] + print(f' [DIAG] gate={gf}/{fi}f | ' + f'tracks={len(diag_info["unique_tids"])} | ' + f'id_sw={diag_info["id_switches"]}') + pk = max(diag_info['jump_peak_scores'].values()) if diag_info['jump_peak_scores'] else 0.0 + print(f' [DIAG] jump_peak={pk:.2f} | tail_max_streak={diag_info["tail_max_streak"]}') + + cap.release() + if show: + cv2.destroyAllWindows() + + events_path = str(Path(output_dir) / 'events.json') + with open(events_path, 'w', encoding='utf-8') as f: + json.dump([{ + 'event_type': c.event_type, + 'start_frame': c.start_frame, + 'end_frame': c.end_frame, + 'track_ids': c.track_ids, + 'confidence': round(c.confidence, 4), + 'reason': c.reason, + 'status': c.status, + } for c in all_c], f, indent=2, ensure_ascii=False) + + return all_c, ann_frames, diag_info diff --git a/ai/requirements.txt b/ai/requirements.txt new file mode 100644 index 0000000..9ca522b --- /dev/null +++ b/ai/requirements.txt @@ -0,0 +1,11 @@ +ultralytics>=8.3.0 +supervision>=0.22.0 +opencv-python>=4.9.0 +httpx>=0.27.0 +torch>=2.2.0 +torchvision>=0.17.0 +numpy>=1.24.0 +tqdm>=4.66.0 +python-jose[cryptography]>=3.3.0 +python-dotenv>=1.0.0 +huggingface_hub>=0.23.0 diff --git a/ai/rules.py b/ai/rules.py new file mode 100644 index 0000000..0d1ab40 --- /dev/null +++ b/ai/rules.py @@ -0,0 +1,290 @@ +from __future__ import annotations +from collections import deque, defaultdict +from typing import Dict, List, Optional, Set + +import numpy as np + +from ai.types import EventCandidate, GateZoneConfig, TrackedPerson, near_gate_x, bbox_overlap_ratio + + +class JumpDetector: + """ + y2(발 끝) 변위 기반 점프 감지. + - dynamic_multiplier : threshold = 정상 y2 변위 평균 × multiplier + - min_score : score(y2 변위/키) 최소값 + - _gate_min : threshold 하한. gate 탐지 시 gate_height × 0.08로 자동 갱신 + """ + + def __init__(self, window_frames: int = 30, jump_ratio: float = 0.30, + calibration_frames: int = 90, dynamic_multiplier: float = 5.0, + min_score: float = 0.25): + self.window_frames = window_frames + self.jump_ratio = jump_ratio + self.calibration_frames = calibration_frames + self.dynamic_multiplier = dynamic_multiplier + self.min_score = min_score + self._history: dict = {} + self._frame_count: int = 0 + self._calib_disps: list = [] + self._dynamic_threshold: Optional[float] = None + self._gate_min: float = 30.0 + + def set_gate_height(self, gate_h: float): + self._gate_min = max(gate_h * 0.08, 15.0) + if self._dynamic_threshold is not None: + self._dynamic_threshold = max(self._dynamic_threshold, self._gate_min) + + def _upward_disp(self, tid): + h = self._history.get(tid) + if not h or len(h) < 5: + return 0.0, 0.0 + y2_vals = [y2 for y2, _ in h] + avg_h = float(np.mean([ht for _, ht in h])) + n = len(y2_vals) + base = float(np.mean(y2_vals[:max(1, n // 4)])) + return base - min(y2_vals), avg_h + + def _try_calibrate(self): + if not self._calib_disps: + return + mean_disp = float(np.mean(self._calib_disps)) + self._dynamic_threshold = max(mean_disp * self.dynamic_multiplier, self._gate_min) + print(f'[JumpDetector] 보정 완료: 정상 y2 변위={mean_disp:.1f}px ' + f'→ threshold={self._dynamic_threshold:.1f}px ' + f'(×{self.dynamic_multiplier}, min={self._gate_min:.1f}px)') + + def update(self, tid: int, xyxy): + x1, y1, x2, y2 = xyxy + h = y2 - y1 + if tid not in self._history: + self._history[tid] = deque(maxlen=self.window_frames) + self._history[tid].append((y2, h)) + if self._frame_count < self.calibration_frames: + disp, _ = self._upward_disp(tid) + if disp > 0: + self._calib_disps.append(disp) + elif self._dynamic_threshold is None: + self._try_calibrate() + self._frame_count += 1 + + def is_jump(self, tid: int) -> bool: + upward, avg_h = self._upward_disp(tid) + if avg_h == 0: + return False + score = upward / avg_h + if score < self.min_score: + return False + if self._dynamic_threshold is not None: + return upward > self._dynamic_threshold + return score > self.jump_ratio + + def get_score(self, tid: int) -> float: + upward, avg_h = self._upward_disp(tid) + return upward / avg_h if avg_h > 0 else 0.0 + + def transfer_history(self, old_tid: int, new_tid: int): + """ID switch 보정: old_tid 히스토리를 new_tid로 이어받기.""" + if old_tid in self._history: + self._history[new_tid] = self._history.pop(old_tid) + + def cleanup(self, active_tids: Set[int]): + for tid in list(self._history.keys()): + if tid not in active_tids: + del self._history[tid] + + +class IDRemapper: + def __init__(self, dist_thresh: int = 80, frame_thresh: int = 15): + self.dist_thresh = dist_thresh + self.frame_thresh = frame_thresh + self._lost: dict = {} + self._remap: dict = {} + + def canonical(self, tid: int) -> int: + return self._remap.get(tid, tid) + + def update(self, fi: int, persons: List[TrackedPerson], prev_tids: Set[int]) -> dict: + current_tids = {p.track_id for p in persons} + for tid in prev_tids - current_tids: + canonical = self._remap.get(tid, tid) + if canonical not in self._lost: + self._lost[canonical] = (None, None, fi) + expired = [tid for tid, (_, _, f) in self._lost.items() if fi - f > self.frame_thresh] + for tid in expired: + del self._lost[tid] + new_mappings = {} + known_canonicals = set(self._remap.values()) + for p in persons: + if p.track_id in self._remap or p.track_id in known_canonicals: + continue + best_match, best_dist = None, self.dist_thresh + for old_tid, (cx, cy, _) in self._lost.items(): + if cx is None: + continue + dist = ((p.cx - cx) ** 2 + (p.cy - cy) ** 2) ** 0.5 + if dist < best_dist: + best_dist = dist + best_match = old_tid + if best_match is not None: + self._remap[p.track_id] = best_match + new_mappings[p.track_id] = best_match + del self._lost[best_match] + return new_mappings + + def record_positions(self, persons: List[TrackedPerson]): + for p in persons: + canonical = self._remap.get(p.track_id, p.track_id) + if canonical in self._lost: + cx, cy, f = self._lost[canonical] + self._lost[canonical] = (p.cx, p.cy, f) + + +class CrawlingRule: + """AR=1.6, min_frames=10 (v6)""" + + def __init__(self, cfg: GateZoneConfig): + self.cfg = cfg + self._ar_buf: dict = defaultdict(lambda: deque(maxlen=60)) + self._low_frames: dict = defaultdict(int) + self._triggered: set = set() + self.debug: bool = False + self._max_low: dict = defaultdict(int) + + def update(self, fi: int, persons: List[TrackedPerson], + cached_gate=None) -> List[EventCandidate]: + mf = self.cfg.crawl_min_frames + th = self.cfg.crawl_aspect_ratio_thresh + out = [] + active = {p.track_id for p in persons} + + for p in persons: + if not near_gate_x(p, cached_gate, margin_ratio=0.3): + if self.debug and fi % 30 == 0: + print(f'[CRAWL] f={fi} ID={p.track_id} SKIP near_gate_x=False') + self._low_frames[p.track_id] = 0 + continue + self._ar_buf[p.track_id].append(p.aspect_ratio) + median_ar = float(np.median(list(self._ar_buf[p.track_id]))) + if median_ar < th: + self._low_frames[p.track_id] += 1 + self._max_low[p.track_id] = max(self._max_low[p.track_id], + self._low_frames[p.track_id]) + if self.debug and self._low_frames[p.track_id] % 5 == 0: + print(f'[CRAWL] f={fi} ID={p.track_id} ' + f'AR={median_ar:.2f}<{th} cnt={self._low_frames[p.track_id]}/{mf}') + else: + cnt = self._low_frames[p.track_id] + if cnt >= mf and p.track_id not in self._triggered: + self._triggered.add(p.track_id) + if self.debug: + print(f'[CRAWL ✓] f={fi} ID={p.track_id} ' + f'TRIGGERED AR={median_ar:.2f} cnt={cnt}') + out.append(EventCandidate( + 'crawling', fi - cnt, fi, [p.track_id], + min(1.0, cnt / (mf * 3)), + f'AR={median_ar:.2f}<{th} {cnt}프레임')) + self._low_frames[p.track_id] = 0 + + for tid in list(self._low_frames): + if tid not in active: + cnt = self._low_frames[tid] + if cnt >= mf and tid not in self._triggered: + self._triggered.add(tid) + out.append(EventCandidate( + 'crawling', fi - cnt, fi, [tid], + min(1.0, cnt / (mf * 3)), + f'AR<{th} {cnt}프레임 후 시야이탈')) + del self._low_frames[tid] + if tid in self._ar_buf: + del self._ar_buf[tid] + return out + + +class TailgatingRule: + """min=5, overlap>=0.25, max_dist=200px (v6)""" + + def __init__(self, cfg: GateZoneConfig): + self.cfg = cfg + self.min_cooccupy_frames = cfg.tailgating_min_frames + self.gate_overlap_thresh = cfg.gate_overlap_thresh + self.max_dist = cfg.tailgate_max_dist + self._pair_streak: dict = {} + self._triggered: set = set() + self._cy_history: dict = defaultdict(lambda: deque(maxlen=30)) + self.debug: bool = False + + def _movement_dir(self, tid: int) -> float: + h = list(self._cy_history[tid]) + if len(h) < 3: + return 0.0 + return h[-1] - h[0] + + def _in_gate(self, p: TrackedPerson, gx1, gy1, gx2, gy2): + ratio = bbox_overlap_ratio(p.x1, p.y1, p.x2, p.y2, gx1, gy1, gx2, gy2) + return ratio >= self.gate_overlap_thresh, ratio + + def update(self, fi: int, persons: List[TrackedPerson], fps: float, + cached_gate=None) -> List[EventCandidate]: + out = [] + if cached_gate: + gx1, gy1 = cached_gate['x1'], cached_gate['y1'] + gx2, gy2 = cached_gate['x2'], cached_gate['y2'] + else: + gz = self.cfg.gate_zone + gx1, gy1, gx2, gy2 = gz.x1, gz.y1, gz.x2, gz.y2 + + in_gate_persons = [] + overlap_map = {} + for p in persons: + inside, ratio = self._in_gate(p, gx1, gy1, gx2, gy2) + overlap_map[p.track_id] = ratio + if inside: + in_gate_persons.append(p) + self._cy_history[p.track_id].append(p.cy) + + if self.debug and fi % 15 == 0: + print(f'[TAIL DEBUG] frame={fi:5d}, persons={len(persons)}, ' + f'in_gate={len(in_gate_persons)}, ' + f'max_streak={max(self._pair_streak.values(), default=0)}') + + current_pairs = set() + if len(in_gate_persons) >= 2: + for i in range(len(in_gate_persons)): + for j in range(i + 1, len(in_gate_persons)): + pa = in_gate_persons[i] + pb = in_gate_persons[j] + dist_ij = ((pa.cx - pb.cx) ** 2 + (pa.cy - pb.cy) ** 2) ** 0.5 + if dist_ij > self.max_dist: + if self.debug: + print(f'[TAIL DEBUG] dist 필터: ' + f'ID{pa.track_id}-ID{pb.track_id} ' + f'{dist_ij:.0f}px>{self.max_dist}px → skip') + continue + current_pairs.add(frozenset({pa.track_id, pb.track_id})) + + for key in list(self._pair_streak.keys()): + if key not in current_pairs: + del self._pair_streak[key] + + for key in current_pairs: + self._pair_streak[key] = self._pair_streak.get(key, 0) + 1 + streak = self._pair_streak[key] + if streak >= self.min_cooccupy_frames and key not in self._triggered: + ta, tb = list(key) + pa = next((p for p in in_gate_persons if p.track_id == ta), in_gate_persons[0]) + pb = next((p for p in in_gate_persons if p.track_id == tb), in_gate_persons[1]) + dir_a = self._movement_dir(ta) + dir_b = self._movement_dir(tb) + if dir_a != 0.0 and dir_b != 0.0 and dir_a * dir_b < 0: + continue # 교행 방향 → skip + self._triggered.add(key) + dist = ((pa.cx - pb.cx) ** 2 + (pa.cy - pb.cy) ** 2) ** 0.5 + dir_label = '↓' if dir_a >= 0 else '↑' + ovlp_a = overlap_map.get(ta, 0.0) + ovlp_b = overlap_map.get(tb, 0.0) + out.append(EventCandidate( + 'tailgating', fi - streak + 1, fi, [ta, tb], 0.75, + f'gate 동시 점유 {streak}f(min={self.min_cooccupy_frames}), ' + f'방향({dir_label}), dist={dist:.0f}px, ' + f'overlap=({ovlp_a:.2f}/{ovlp_b:.2f})')) + return out diff --git a/ai/test_video/00.mp4 b/ai/test_video/00.mp4 new file mode 100644 index 0000000..e9eab49 Binary files /dev/null and b/ai/test_video/00.mp4 differ diff --git a/ai/test_video/2112765.mp4 b/ai/test_video/2112765.mp4 new file mode 100644 index 0000000..4727e7c Binary files /dev/null and b/ai/test_video/2112765.mp4 differ diff --git a/ai/test_video/2248467.mp4 b/ai/test_video/2248467.mp4 new file mode 100644 index 0000000..a2ac8ed Binary files /dev/null and b/ai/test_video/2248467.mp4 differ diff --git "a/ai/test_video/\354\227\260\352\265\254\354\213\244_crawling.mp4" "b/ai/test_video/\354\227\260\352\265\254\354\213\244_crawling.mp4" new file mode 100644 index 0000000..279e85d Binary files /dev/null and "b/ai/test_video/\354\227\260\352\265\254\354\213\244_crawling.mp4" differ diff --git a/ai/tracker.py b/ai/tracker.py index 5b6f0da..579a44d 100644 --- a/ai/tracker.py +++ b/ai/tracker.py @@ -5,13 +5,15 @@ class PersonTracker: def __init__(self): - self.tracker = sv.ByteTracker() + self.tracker = sv.ByteTrack() self.annotator = sv.BoxAnnotator() + self.label_annotator = sv.LabelAnnotator() def update(self, detections: sv.Detections) -> sv.Detections: """YOLO 감지 결과를 받아 tracker_id가 부여된 Detections 반환""" return self.tracker.update_with_detections(detections) def annotate(self, frame: np.ndarray, detections: sv.Detections) -> np.ndarray: - labels = [f"ID:{tid}" for tid in (detections.tracker_id or [])] - return self.annotator.annotate(frame.copy(), detections=detections, labels=labels) + labels = [f"ID:{tid}" for tid in (detections.tracker_id if detections.tracker_id is not None else [])] + annotated = self.annotator.annotate(frame.copy(), detections=detections) + return self.label_annotator.annotate(annotated, detections=detections, labels=labels) diff --git a/ai/types.py b/ai/types.py new file mode 100644 index 0000000..cdcb1ec --- /dev/null +++ b/ai/types.py @@ -0,0 +1,147 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class Detection: + x1: float; y1: float; x2: float; y2: float; confidence: float + + @property + def cx(self): return (self.x1 + self.x2) / 2 + @property + def cy(self): return (self.y1 + self.y2) / 2 + @property + def width(self): return self.x2 - self.x1 + @property + def height(self): return self.y2 - self.y1 + @property + def aspect_ratio(self): return self.height / (self.width + 1e-6) + + +@dataclass +class Zone: + x1: int; y1: int; x2: int; y2: int + + def contains_point(self, x, y): + return self.x1 <= x <= self.x2 and self.y1 <= y <= self.y2 + + +@dataclass +class PassLine: + x1: int; y1: int; x2: int; y2: int + + +@dataclass +class GateZoneConfig: + camera_id: str + frame_width: int + frame_height: int + gate_zone: Zone + pass_line: PassLine + jump_zone: Zone + crawl_zone: Zone + tailgate_time_window_s: float = 3.0 + tailgate_distance_thresh: float = 200.0 + unpaid_hold_s: float = 5.0 + jump_min_frames: int = 3 + crawl_min_frames: int = 10 + crawl_aspect_ratio_thresh: float = 1.6 + tailgating_min_frames: int = 5 + gate_overlap_thresh: float = 0.25 + tailgate_max_dist: float = 200.0 + + @classmethod + def from_dict(cls, d): + return cls( + camera_id=d['camera_id'], + frame_width=d['frame_width'], + frame_height=d['frame_height'], + gate_zone=Zone(**d['gate_zone']), + pass_line=PassLine(**d['pass_line']), + jump_zone=Zone(**d['jump_zone']), + crawl_zone=Zone(**d['crawl_zone']), + tailgate_time_window_s =d.get('tailgate_time_window_s', 3.0), + tailgate_distance_thresh =d.get('tailgate_distance_thresh', 200.0), + unpaid_hold_s =d.get('unpaid_hold_s', 5.0), + jump_min_frames =d.get('jump_min_frames', 3), + crawl_min_frames =d.get('crawl_min_frames', 10), + crawl_aspect_ratio_thresh=d.get('crawl_aspect_ratio_thresh', 1.6), + tailgating_min_frames =d.get('tailgating_min_frames', 5), + gate_overlap_thresh =d.get('gate_overlap_thresh', 0.25), + tailgate_max_dist =d.get('tailgate_max_dist', 200.0), + ) + + +@dataclass +class TrackedPerson: + track_id: int + x1: float; y1: float; x2: float; y2: float + + @property + def cx(self): return (self.x1 + self.x2) / 2 + @property + def cy(self): return (self.y1 + self.y2) / 2 + @property + def aspect_ratio(self): return (self.y2 - self.y1) / ((self.x2 - self.x1) + 1e-6) + + +@dataclass +class EventCandidate: + event_type: str + start_frame: int + end_frame: int + track_ids: List[int] + confidence: float + reason: str + status: str = 'candidate' + efficientnet_result: Optional[dict] = None + clip_path: Optional[str] = None + + +@dataclass +class GateRelation: + in_gate: bool + rel_x: float + rel_y: float + vertical_zone: str # 'top' | 'middle' | 'bottom' + overlap_iou: float + + +def compute_gate_relation(person: TrackedPerson, gate: dict) -> Optional[GateRelation]: + if gate is None: + return None + gx1, gy1, gx2, gy2 = gate['x1'], gate['y1'], gate['x2'], gate['y2'] + gw = gx2 - gx1; gh = gy2 - gy1 + if gw <= 0 or gh <= 0: + return None + in_gate = (gx1 <= person.cx <= gx2) and (gy1 <= person.cy <= gy2) + rel_x = (person.cx - gx1) / gw + rel_y = (person.cy - gy1) / gh + zone = 'top' if rel_y < 0.33 else ('middle' if rel_y < 0.66 else 'bottom') + ix1 = max(person.x1, gx1); iy1 = max(person.y1, gy1) + ix2 = min(person.x2, gx2); iy2 = min(person.y2, gy2) + inter = max(0, ix2 - ix1) * max(0, iy2 - iy1) + union = (person.x2 - person.x1) * (person.y2 - person.y1) + gw * gh - inter + iou = inter / union if union > 0 else 0.0 + return GateRelation(in_gate=in_gate, rel_x=rel_x, rel_y=rel_y, + vertical_zone=zone, overlap_iou=iou) + + +def near_gate_x(person: TrackedPerson, gate: Optional[dict], margin_ratio: float = 0.2) -> bool: + """person cx가 gate x-range ± margin 안에 있는지. gate 없으면 True.""" + if gate is None: + return True + gx1, gx2 = gate['x1'], gate['x2'] + margin = (gx2 - gx1) * margin_ratio + return (gx1 - margin) <= person.cx <= (gx2 + margin) + + +def bbox_overlap_ratio(px1, py1, px2, py2, gx1, gy1, gx2, gy2) -> float: + ix1 = max(px1, gx1); iy1 = max(py1, gy1) + ix2 = min(px2, gx2); iy2 = min(py2, gy2) + if ix2 <= ix1 or iy2 <= iy1: + return 0.0 + inter = (ix2 - ix1) * (iy2 - iy1) + p_area = max((px2 - px1) * (py2 - py1), 1.0) + return inter / p_area diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 0000000..9d2be70 Binary files /dev/null and b/backend/.coverage differ diff --git a/backend/alembic/versions/3dfc3e213f69_add_compression_and_performance_indexes.py b/backend/alembic/versions/3dfc3e213f69_add_compression_and_performance_indexes.py new file mode 100644 index 0000000..fcb9641 --- /dev/null +++ b/backend/alembic/versions/3dfc3e213f69_add_compression_and_performance_indexes.py @@ -0,0 +1,44 @@ +"""add_compression_and_performance_indexes + +Revision ID: 3dfc3e213f69 +Revises: 9f79ef3630c6 +Create Date: 2026-03-31 10:59:41.856207 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3dfc3e213f69' +down_revision: Union[str, None] = '9f79ef3630c6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. TimescaleDB 압축 활성화 (7일 이상 데이터 대상) + # camera_id로 세그먼트를 나누어 저장 효율과 쿼리 성능을 동시에 잡습니다. + op.execute(""" + ALTER TABLE events SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'camera_id', + timescaledb.compress_orderby = 'timestamp DESC' + ); + """) + op.execute("SELECT add_compression_policy('events', INTERVAL '7 days');") + + # 2. 고성능 쿼리용 복합 인덱스 추가 + # 대시보드에서 카메라별 최신 이벤트를 조회할 때 사용됩니다. + op.create_index('ix_events_camera_time', 'events', ['camera_id', 'timestamp'], postgresql_using='btree') + # 특정 타입(jump 등) 무임승차 조회를 최적화합니다. + op.create_index('ix_events_type_time', 'events', ['event_type', 'timestamp'], postgresql_using='btree') + + +def downgrade() -> None: + op.drop_index('ix_events_type_time', table_name='events') + op.drop_index('ix_events_camera_time', table_name='events') + op.execute("SELECT remove_compression_policy('events');") + op.execute("ALTER TABLE events SET (timescaledb.compress = false);") diff --git a/backend/alembic/versions/873602d4c842_add_dispatch_fields_to_event.py b/backend/alembic/versions/873602d4c842_add_dispatch_fields_to_event.py new file mode 100644 index 0000000..aa8dded --- /dev/null +++ b/backend/alembic/versions/873602d4c842_add_dispatch_fields_to_event.py @@ -0,0 +1,34 @@ +"""add_dispatch_fields_to_event + +Revision ID: 873602d4c842 +Revises: d455377f82ef +Create Date: 2026-03-31 12:12:21.909268 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '873602d4c842' +down_revision: Union[str, None] = 'd455377f82ef' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('events', sa.Column('handled_by', sa.Integer(), nullable=True)) + op.add_column('events', sa.Column('handled_at', sa.DateTime(), nullable=True)) + op.create_foreign_key(None, 'events', 'admins', ['handled_by'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'events', type_='foreignkey') + op.drop_column('events', 'handled_at') + op.drop_column('events', 'handled_by') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/93cde590b2fc_create_hourly_event_stats_materialized_.py b/backend/alembic/versions/93cde590b2fc_create_hourly_event_stats_materialized_.py new file mode 100644 index 0000000..fae78b5 --- /dev/null +++ b/backend/alembic/versions/93cde590b2fc_create_hourly_event_stats_materialized_.py @@ -0,0 +1,50 @@ +"""Create hourly_event_stats materialized view + +Revision ID: 93cde590b2fc +Revises: f44abc650fea +Create Date: 2026-03-31 10:38:59.543675 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '93cde590b2fc' +down_revision: Union[str, None] = 'f44abc650fea' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # [GateGuard] Continuous Aggregate 생성은 트랜잭션 밖에서 실행되어야 함 + with op.get_context().autocommit_block(): + # [GateGuard] TimescaleDB Continuous Aggregate 생성 + # 실시간으로 데이터가 집계되며 대시보드 로딩 속도를 비약적으로 향상시킵니다. + op.execute(""" + CREATE MATERIALIZED VIEW hourly_event_stats + WITH (timescaledb.continuous) AS + SELECT + c.station_name, + e.event_type, + time_bucket('1 hour', e.timestamp) as hour, + count(*) as event_count + FROM events e + JOIN cameras c ON e.camera_id = c.id + GROUP BY c.station_name, e.event_type, hour; + """) + + # [GateGuard] 자동 갱신 정책 추가 (최근 24시간 데이터를 30분마다 갱신) + op.execute(""" + SELECT add_continuous_aggregate_policy('hourly_event_stats', + start_offset => INTERVAL '24 hours', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '30 minutes'); + """) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("DROP MATERIALIZED VIEW IF EXISTS hourly_event_stats CASCADE;") diff --git a/backend/alembic/versions/9f79ef3630c6_upgrade_admin_and_retention_policy.py b/backend/alembic/versions/9f79ef3630c6_upgrade_admin_and_retention_policy.py new file mode 100644 index 0000000..4886f20 --- /dev/null +++ b/backend/alembic/versions/9f79ef3630c6_upgrade_admin_and_retention_policy.py @@ -0,0 +1,37 @@ +"""upgrade_admin_and_retention_policy + +Revision ID: 9f79ef3630c6 +Revises: 93cde590b2fc +Create Date: 2026-03-31 10:53:40.939094 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9f79ef3630c6' +down_revision: Union[str, None] = '93cde590b2fc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # [GateGuard] 관리자 권한 및 소속 관리 필드 추가 + op.add_column('admins', sa.Column('role', sa.String(), nullable=False, server_default='viewer')) + op.add_column('admins', sa.Column('station_name', sa.String(), nullable=True)) + op.add_column('admins', sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true')) + + # [GateGuard] 시계열 데이터 관리: 90일 이상된 이벤트 자동 아카이빙/삭제 정책 수립 + op.execute("SELECT add_retention_policy('events', INTERVAL '90 days', if_not_exists => true);") + + +def downgrade() -> None: + # [GateGuard] 보관 정책 해제 + op.execute("SELECT remove_retention_policy('events', if_exists => true);") + + op.drop_column('admins', 'is_active') + op.drop_column('admins', 'station_name') + op.drop_column('admins', 'role') diff --git a/backend/alembic/versions/b5521f8a3c10_enable_timescaledb_and_create_hypertable.py b/backend/alembic/versions/b5521f8a3c10_enable_timescaledb_and_create_hypertable.py new file mode 100644 index 0000000..efd7a91 --- /dev/null +++ b/backend/alembic/versions/b5521f8a3c10_enable_timescaledb_and_create_hypertable.py @@ -0,0 +1,36 @@ +""" +Enable TimescaleDB and create hypertable for events +Revision ID: b5521f8a3c10 +Revises: a4437e459dcf +Create Date: 2026-03-28 17:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "b5521f8a3c10" +down_revision: Union[str, None] = "a4437e459dcf" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;") + op.execute("ALTER TABLE notifications DROP CONSTRAINT notifications_event_id_fkey;") + op.execute("ALTER TABLE events DROP CONSTRAINT events_pkey;") + op.execute("ALTER TABLE events ADD PRIMARY KEY (id, timestamp);") + op.execute( + "SELECT create_hypertable('events', 'timestamp', " + "chunk_time_interval => INTERVAL '7 days', " + "migrate_data => true, " + "if_not_exists => true);" + ) + + op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_event_id ON notifications (event_id);") + + +def downgrade() -> None: + op.execute("ALTER TABLE events DROP CONSTRAINT events_pkey;") + op.execute("ALTER TABLE events ADD PRIMARY KEY (id);") + op.execute("DROP EXTENSION IF EXISTS timescaledb CASCADE;") diff --git a/backend/alembic/versions/d455377f82ef_add_employee_id_to_admin.py b/backend/alembic/versions/d455377f82ef_add_employee_id_to_admin.py new file mode 100644 index 0000000..70f23ad --- /dev/null +++ b/backend/alembic/versions/d455377f82ef_add_employee_id_to_admin.py @@ -0,0 +1,32 @@ +"""add_employee_id_to_admin + +Revision ID: d455377f82ef +Revises: b5521f8a3c10 +Create Date: 2026-03-31 12:08:08.002264 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd455377f82ef' +down_revision: Union[str, None] = 'b5521f8a3c10' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('admins', sa.Column('employee_id', sa.String(), nullable=False)) + op.create_index(op.f('ix_admins_employee_id'), 'admins', ['employee_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_admins_employee_id'), table_name='admins') + op.drop_column('admins', 'employee_id') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/f44abc650fea_add_event_type_to_events_table.py b/backend/alembic/versions/f44abc650fea_add_event_type_to_events_table.py new file mode 100644 index 0000000..67de829 --- /dev/null +++ b/backend/alembic/versions/f44abc650fea_add_event_type_to_events_table.py @@ -0,0 +1,30 @@ +"""Add event_type to events table + +Revision ID: f44abc650fea +Revises: 873602d4c842 +Create Date: 2026-03-31 10:38:06.169141 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f44abc650fea' +down_revision: Union[str, None] = '873602d4c842' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('events', sa.Column('event_type', sa.String(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('events', 'event_type') + # ### end Alembic commands ### diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 67bf11b..0d4da70 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -2,21 +2,62 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import create_access_token, verify_password +from app.core.security import create_access_token, verify_password, hash_password from app.database import get_db from app.models.models import Admin -from app.schemas.schemas import AdminLogin, TokenResponse +from app.schemas.schemas import AdminLogin, AdminRegister, TokenResponse router = APIRouter(prefix="/auth", tags=["auth"]) +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register(body: AdminRegister, db: AsyncSession = Depends(get_db)): + """[GateGuard] 새로운 관리자를 사원번호 기반으로 등록합니다.""" + # 1. 중복 확인 + result = await db.execute(select(Admin).where( + (Admin.employee_id == body.employee_id) | (Admin.email == body.email) + )) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="이미 등록된 사원번호 또는 이메일입니다.") + + # 2. 계정 생성 (비밀번호 해싱 완료) + new_admin = Admin( + employee_id=body.employee_id, + email=body.email, + password=hash_password(body.password) + ) + db.add(new_admin) + await db.commit() + await db.refresh(new_admin) + + # 3. 즉시 토큰 발행 + token = create_access_token({"sub": str(new_admin.id), "employee_id": new_admin.employee_id}) + return TokenResponse(access_token=token) + + @router.post("/login", response_model=TokenResponse) async def login(body: AdminLogin, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Admin).where(Admin.email == body.email)) + """[GateGuard] 사원번호를 통해 로그인합니다.""" + result = await db.execute(select(Admin).where(Admin.employee_id == body.employee_id)) admin = result.scalar_one_or_none() if not admin or not verify_password(body.password, admin.password): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="이메일 또는 비밀번호가 올바르지 않습니다.") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="사원번호 또는 비밀번호가 올바르지 않습니다.") - token = create_access_token({"sub": str(admin.id), "email": admin.email}) + token = create_access_token({"sub": str(admin.id), "employee_id": admin.employee_id}) return TokenResponse(access_token=token) + + +@router.post("/find-pw") +async def find_password(employee_id: str, email: str, db: AsyncSession = Depends(get_db)): + """[GateGuard] 사원번호와 이메일 대조를 통해 임시 비밀번호를 발급하거나 안내합니다.""" + result = await db.execute(select(Admin).where( + (Admin.employee_id == employee_id) & (Admin.email == email) + )) + admin = result.scalar_one_or_none() + + if not admin: + raise HTTPException(status_code=404, detail="일치하는 사원 정보가 없습니다.") + + # 🚩 수근팀장님 TODO: 실제 이메일 발송 로직 또는 임시 PW 저장 로직 추가 + return {"message": "입력하신 이메일로 비밀번호 재설정 안내가 전송되었습니다. (Demo Mode)"} diff --git a/backend/app/api/events.py b/backend/app/api/events.py index fee3693..c94e90b 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -1,3 +1,5 @@ +from typing import Optional +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -8,6 +10,11 @@ from app.models.models import Event, Notification, Admin from app.schemas.schemas import EventCreate, EventResponse, EventStatusUpdate from app.workers.tasks import upload_clip_task +from pydantic import BaseModel +from sqlalchemy import func + +class FalseAlarmRequest(BaseModel): + reason: str # 라우터 기초 설정 (최종 통합본 v1) router = APIRouter(prefix="/events", tags=["events"]) @@ -15,39 +22,111 @@ @router.get("/", response_model=list[EventResponse]) async def list_events( - camera_id: int | None = None, - status: str | None = None, + camera_id: Optional[int] = None, + status: Optional[str] = None, + type: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, limit: int = 50, + offset: int = 0, db: AsyncSession = Depends(get_db), current_admin: Admin = Depends(get_current_admin) -): +) -> list[Event]: """ [통합 v1] 모든 무임승차 이벤트를 최신순으로 조회합니다. (보안 인증 필수) + + Args: + camera_id: 특정 카메라 ID로 필터링 (선택) + status: 이벤트 상태로 필터링 (선택) + type: 무임승차 유형별 필터링 (선택) + date_from: 시작일 (선택) + date_to: 종료일 (선택) + limit: 조회할 최대 이벤트 개수 (기본값: 50) + offset: 페이지네이션 오프셋 (기본값: 0) + db: SQLAlchemy 비동기 세션 + current_admin: 인증된 관리자 객체 + + Returns: + list[Event]: 조회된 이벤트 객체 리스트 """ - query = select(Event).order_by(Event.timestamp.desc()).limit(limit) - if camera_id: + query = select(Event).order_by(Event.timestamp.desc()).offset(offset).limit(limit) + + if camera_id is not None: query = query.where(Event.camera_id == camera_id) if status: query = query.where(Event.status == status) + if type: + query = query.where(Event.event_type == type) + if date_from: + query = query.where(Event.timestamp >= date_from) + if date_to: + query = query.where(Event.timestamp <= date_to) result = await db.execute(query) return result.scalars().all() +@router.get("/stats") +async def get_event_stats( + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin) +): + """[M2] 대시보드 상단 통계 카드 데이터 조회 API""" + start_of_day = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + today_total = await db.scalar(select(func.count(Event.id)).where(Event.timestamp >= start_of_day)) + pending_count = await db.scalar(select(func.count(Event.id)).where(Event.status == 'pending')) + confirmed_count = await db.scalar(select(func.count(Event.id)).where(Event.status == 'confirmed')) + false_alarm_count = await db.scalar(select(func.count(Event.id)).where(Event.status == 'dismissed')) + + return { + "today_total": today_total or 0, + "pending": pending_count or 0, + "confirmed": confirmed_count or 0, + "false_alarm": false_alarm_count or 0 + } + + +@router.get("/stats/by-camera") +async def get_camera_stats( + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin) +): + """[M2] 대시보드 우측 역별/게이트별 통계 (카메라별 집계) API""" + query = select(Event.camera_id, func.count(Event.id).label("count")).group_by(Event.camera_id) + result = await db.execute(query) + return [{"camera_id": row.camera_id, "count": row.count} for row in result.all()] + + +@router.get("/{event_id}", response_model=EventResponse) +async def get_event( + event_id: int, + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin) +): + """[M2] 단건 이벤트 상세보기 API (상세보기 버튼 동작)""" + result = await db.execute(select(Event).where(Event.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="해당 사건 기록을 찾을 수 없습니다.") + return event + + @router.post("/", response_model=EventResponse, status_code=201) async def create_event( body: EventCreate, db: AsyncSession = Depends(get_db) -): +) -> Event: """ - [통합 v1] AI 실시간 추론 결과로부터 이벤트를 기록하고 방송합니다. + [GateGuard] AI 실시간 추론 결과로부터 무임승차 이벤트를 기록하고 관제 대시보드에 즉시 브로드캐스트합니다. + - AI 추론 엔진(inference.py)에서 호출됩니다. """ # 1. DB에 사건 기록 (Persistence) event = Event(**body.model_dump()) db.add(event) await db.flush() - # 2. 실시간 알림 레코드 생성 + # 2. 실시간 알림 레코드 생성 (관제 기록용) notification = Notification(event_id=event.id) db.add(notification) await db.commit() @@ -59,22 +138,58 @@ async def create_event( "data": EventResponse.model_validate(event).model_dump() }) - # 4. 🎥 [비동기 위임] 영상 클립은 무거우니 셀러리 워커에 위임하여 처리 + # 4. 🎥 [비동기 위임] 영상 클립은 무거우니 Celery 워커에 스케줄링 위임 if event.clip_url: upload_clip_task.delay(event.id, event.clip_url) return event +@router.post("/{event_id}/false-alarm", response_model=EventResponse) +async def report_false_alarm( + event_id: int, + body: FalseAlarmRequest, + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin) +): + """[M2] 오탐 신고 접수 API""" + result = await db.execute(select(Event).where(Event.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="해당 사건 기록을 찾을 수 없습니다.") + + event.status = "dismissed" + event.handled_by = current_admin.id + event.handled_at = datetime.now() + + await db.commit() + await db.refresh(event) + + # 실시간 상태 변경 브로드캐스트 + await manager.broadcast({ + "type": "EVENT_STATUS_UPDATED", + "data": { + "id": event.id, + "status": event.status, + "handled_by_employee_id": current_admin.employee_id, + "reason": body.reason + } + }) + + return event + + @router.patch("/{event_id}/status", response_model=EventResponse) async def update_event_status( event_id: int, body: EventStatusUpdate, db: AsyncSession = Depends(get_db), current_admin: Admin = Depends(get_current_admin) -): +) -> Event: """ - [통합 v1] 무임승차 이벤트의 상태(오감지, 완료 등)를 수동 업데이트합니다. (보안 인증 필수) + [GateGuard] 특정 무임승차 사건의 처리 상태(오감지, 조치완료 등)를 수동 업데이트하고 기록을 남깁니다. + - 권한: 인증된 관리자(Admin)만 가능하며, 조치한 사원의 정보가 영구 기록됩니다. """ result = await db.execute(select(Event).where(Event.id == event_id)) event = result.scalar_one_or_none() @@ -82,7 +197,22 @@ async def update_event_status( if not event: raise HTTPException(status_code=404, detail="해당 사건 기록을 찾을 수 없습니다.") + # 🛡️ 지휘권 각인 event.status = body.status + event.handled_by = current_admin.id + event.handled_at = datetime.now() + await db.commit() await db.refresh(event) + + # 📡 실시간 상태 변경 브로드캐스트 (모든 대시보드 동기화) + await manager.broadcast({ + "type": "EVENT_STATUS_UPDATED", + "data": { + "id": event.id, + "status": event.status, + "handled_by_employee_id": current_admin.employee_id + } + }) + return event diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 2707636..66023b6 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -1,31 +1,55 @@ -from datetime import datetime - -from fastapi import APIRouter, Depends -from sqlalchemy import select +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select, update, func from sqlalchemy.ext.asyncio import AsyncSession +from typing import List +from app.api.deps import get_current_admin from app.database import get_db -from app.models.models import Notification +from app.models.models import Notification, Admin from app.schemas.schemas import NotificationResponse router = APIRouter(prefix="/notifications", tags=["notifications"]) - -@router.get("/", response_model=list[NotificationResponse]) -async def list_notifications(unread_only: bool = False, db: AsyncSession = Depends(get_db)): - query = select(Notification).order_by(Notification.sent_at.desc()) +@router.get("/", response_model=List[NotificationResponse]) +async def list_notifications( + limit: int = 50, + unread_only: bool = False, + db: AsyncSession = Depends(get_db), + current_admin: Admin = Depends(get_current_admin) +): + """ + [GateGuard] 알림 이력을 조회합니다. (M2 완결용) + """ + query = select(Notification).order_by(Notification.sent_at.desc()).limit(limit) if unread_only: - query = query.where(Notification.read_at.is_(None)) + query = query.where(Notification.read_at == None) + result = await db.execute(query) return result.scalars().all() - -@router.patch("/{notification_id}/read", response_model=NotificationResponse) -async def mark_as_read(notification_id: int, db: AsyncSession = Depends(get_db)): +@router.patch("/{notification_id}/read") +async def mark_notification_as_read( + notification_id: int, + db: AsyncSession = Depends(get_db) +): + """ + [GateGuard] 특정 알림을 읽음 처리합니다. + """ result = await db.execute(select(Notification).where(Notification.id == notification_id)) notification = result.scalar_one_or_none() - if notification and not notification.read_at: - notification.read_at = datetime.utcnow() - await db.commit() - await db.refresh(notification) - return notification + + if not notification: + raise HTTPException(status_code=404, detail="알림을 찾을 수 없습니다.") + + notification.read_at = func.now() + await db.commit() + return {"message": "Success", "id": notification_id} + +@router.post("/read-all") +async def mark_all_notifications_as_read(db: AsyncSession = Depends(get_db)): + """ + [GateGuard] 모든 알림을 한꺼번에 읽음 처리합니다. + """ + await db.execute(update(Notification).values(read_at=func.now())) + await db.commit() + return {"message": "All notifications marked as read"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7953422..3ecb158 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -26,7 +26,7 @@ class Settings(BaseSettings): # 앱 DEBUG: bool = True - CORS_ORIGINS: List[str] = ["http://localhost:3000"] + CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174", "http://127.0.0.1:5173", "http://127.0.0.1:5174"] settings = Settings() diff --git a/backend/app/core/s3.py b/backend/app/core/s3.py new file mode 100644 index 0000000..68016a4 --- /dev/null +++ b/backend/app/core/s3.py @@ -0,0 +1,83 @@ +import boto3 +import os +import logging +from botocore.exceptions import NoCredentialsError, ClientError +from app.core.config import settings + +logger = logging.getLogger(__name__) + +class S3Client: + """GateGuard S3 업로드 클라이언트 (M2 Perfect)""" + def __init__(self): + self.bucket_name = settings.AWS_S3_BUCKET + self.region = settings.AWS_REGION + self.access_key = settings.AWS_ACCESS_KEY_ID + self.secret_key = settings.AWS_SECRET_ACCESS_KEY + + # 실제 키가 있는지 확인하여 시뮬레이션 여부 결정 + self.is_configured = bool(self.access_key and self.access_key != "your-aws-access-key") + + if self.is_configured: + try: + self.client = boto3.client( + 's3', + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key, + region_name=self.region + ) + logger.info(f"🚀 [S3] Connected to bucket: {self.bucket_name}") + except Exception as e: + logger.error(f"⚠️ [S3] Connection failed: {str(e)}") + self.is_configured = False + else: + logger.warning("🛡️ [S3] AWS Credentials not found. Running in MOCK/SIMULATION mode.") + + async def upload_file(self, local_file_path: str, s3_file_name: str) -> str: + """파일을 S3에 업로드하고 접근 가능한 URL을 반환합니다.""" + if not self.is_configured: + # 🐯 [코순이 Tip] 시뮬레이션 모드: 로컬 파일의 '가상 URL'을 반환 + logger.info(f"📸 [S3 SIMULATION] Mock uploading {local_file_path} to {s3_file_name}") + return f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/simulated/{s3_file_name}" + + try: + # S3 업로드 실행 (Public Read 권한은 보안 정책에 따라 조절 가능) + self.client.upload_file( + local_file_path, + self.bucket_name, + s3_file_name, + ExtraArgs={'ContentType': 'video/mp4'} + ) + + url = f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{s3_file_name}" + logger.info(f"✅ [S3 SUCCESS] File uploaded: {url}") + return url + + except FileNotFoundError: + logger.error(f"❌ [S3 ERROR] Local file not found: {local_file_path}") + return "" + except NoCredentialsError: + logger.error("❌ [S3 ERROR] Credentials not available") + return "" + except ClientError as e: + logger.error(f"❌ [S3 ERROR] AWS Client Error: {str(e)}") + return "" + + def get_presigned_url(self, s3_file_name: str, expires_in: int = 3600) -> str: + """S3 객체에 접근할 수 있는 기간 한정 URL을 생성합니다.""" + if not self.is_configured: + # 🐯 [코순이 Tip] 시뮬레이션 모드: 가상 URL 발급 + return f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/simulated/{s3_file_name}" + + try: + url = self.client.generate_presigned_url( + 'get_object', + Params={'Bucket': self.bucket_name, 'Key': s3_file_name}, + ExpiresIn=expires_in + ) + return url + except ClientError as e: + logger.error(f"❌ [S3 ERROR] Failed to generate presigned URL: {str(e)}") + return "" + +# 싱글톤 인스턴스 +s3_client = S3Client() diff --git a/backend/app/database.py b/backend/app/database.py index d9b3829..ccad331 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -12,5 +12,9 @@ class Base(DeclarativeBase): async def get_db() -> AsyncSession: + """ + [GateGuard] SQLAlchemy 비동기 DB 세션을 생성하고 자동 반납합니다. + - FastAPI의 Depends(get_db)를 통해 주입받아 사용합니다. + """ async with AsyncSessionLocal() as session: yield session diff --git a/backend/app/main.py b/backend/app/main.py index f85d977..02b0995 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from contextlib import asynccontextmanager - -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, ORJSONResponse from fastapi.middleware.cors import CORSMiddleware from app.api import auth, cameras, events, notifications, websocket @@ -10,19 +10,39 @@ @asynccontextmanager async def lifespan(app: FastAPI): - # [통합 v1] 이제 Alembic을 사용하므로, 서버 시작 시 자동 생성은 하지 않습니다. - # async with engine.begin() as conn: - # await conn.run_sync(Base.metadata.create_all) + """ + [GateGuard] 애플리케이션 수명 주기 관리 + - 현재는 Alembic을 통한 마이그레이션 방식을 채택하여 별도의 초기화 로직은 스킵합니다. + """ yield app = FastAPI( - title="GateGuard API", - description="지하철 개찰구 무임승차 자동 감지 시스템", - version="0.1.0", + title="GateGuard API 🛰️", + description="지하철 개찰구 무임승차 자동 감지 및 실시간 관제 시스템 API", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", lifespan=lifespan, + default_response_class=ORJSONResponse, # [GateGuard] 고성능 orjson 엔진 채택 ) + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception) -> ORJSONResponse: + """ + [GateGuard] 서버 전역 예외 처리기 + - 예기치 못한 에러(500) 발생 시 프론트엔드가 인지할 수 있도록 표준 JSON 포맷을 반환합니다. + """ + return ORJSONResponse( + status_code=500, + content={ + "success": False, + "message": "서버 내부 오류가 발생했습니다. (Internal Server Error)", + "detail": str(exc) if settings.DEBUG else "시스템 관리자에게 문의하세요." + }, + ) + app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, @@ -31,6 +51,7 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) +# [GateGuard] 전략적 API 라우터 등록 app.include_router(auth.router, prefix="/api") app.include_router(cameras.router, prefix="/api") app.include_router(events.router, prefix="/api") @@ -38,6 +59,9 @@ async def lifespan(app: FastAPI): app.include_router(websocket.router) -@app.get("/health") +@app.get("/health", tags=["system"]) async def health(): - return {"status": "ok"} + """ + [GateGuard] 서버 건전성 체크 엔드포인트 + """ + return {"status": "ok", "service": "gateguard-backend"} diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 4d2549d..0693c9e 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,4 +1,6 @@ +from __future__ import annotations from datetime import datetime +from typing import Optional, List from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -10,8 +12,12 @@ class Admin(Base): __tablename__ = "admins" id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + employee_id: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) email: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) password: Mapped[str] = mapped_column(String, nullable=False) + role: Mapped[str] = mapped_column(String, default="viewer") # super_admin | station_manager | viewer + station_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) @@ -32,21 +38,35 @@ class Event(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) camera_id: Mapped[int] = mapped_column(Integer, ForeignKey("cameras.id"), nullable=False) timestamp: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True) - clip_url: Mapped[str | None] = mapped_column(String, nullable=True) - track_id: Mapped[int | None] = mapped_column(Integer, nullable=True) - confidence: Mapped[float | None] = mapped_column(Float, nullable=True) + clip_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) + track_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) status: Mapped[str] = mapped_column(String, default="pending") # pending | confirmed | dismissed + event_type: Mapped[str] = mapped_column(String, default="unknown") # tag_fail | jump | emergency_door + handled_by: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("admins.id"), nullable=True) + handled_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) camera: Mapped["Camera"] = relationship("Camera", back_populates="events") - notifications: Mapped[list["Notification"]] = relationship("Notification", back_populates="event") + notifications: Mapped[list["Notification"]] = relationship( + "Notification", + back_populates="event", + primaryjoin="Event.id == Notification.event_id", + foreign_keys="Notification.event_id" + ) class Notification(Base): __tablename__ = "notifications" id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) - event_id: Mapped[int] = mapped_column(Integer, ForeignKey("events.id"), nullable=False) + event_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 🛡️ TimescaleDB 호환: FK 제약 제외 sent_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - - event: Mapped["Event"] = relationship("Event", back_populates="notifications") + read_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + event: Mapped["Event"] = relationship( + "Event", + back_populates="notifications", + primaryjoin="Notification.event_id == Event.id", + foreign_keys="Notification.event_id", + overlaps="notifications" + ) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 5820b65..dad4f60 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -1,15 +1,22 @@ from datetime import datetime +from typing import Optional, List, Literal from pydantic import BaseModel, EmailStr # ── Auth ────────────────────────────────────────────────────────────────────── -class AdminLogin(BaseModel): +class AdminRegister(BaseModel): + employee_id: str email: EmailStr password: str +class AdminLogin(BaseModel): + employee_id: str + password: str + + class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" @@ -35,25 +42,29 @@ class CameraResponse(BaseModel): class EventCreate(BaseModel): camera_id: int - clip_url: str | None = None - track_id: int | None = None - confidence: float | None = None + clip_url: Optional[str] = None + track_id: Optional[int] = None + confidence: Optional[float] = None + event_type: Optional[str] = None class EventResponse(BaseModel): id: int camera_id: int timestamp: datetime - clip_url: str | None - track_id: int | None - confidence: float | None + clip_url: Optional[str] + track_id: Optional[int] + confidence: Optional[float] status: str + event_type: str + handled_by: Optional[int] + handled_at: Optional[datetime] model_config = {"from_attributes": True} class EventStatusUpdate(BaseModel): - status: str # confirmed | dismissed + status: Literal["confirmed", "dismissed"] # ── Notification ────────────────────────────────────────────────────────────── @@ -62,6 +73,6 @@ class NotificationResponse(BaseModel): id: int event_id: int sent_at: datetime - read_at: datetime | None + read_at: Optional[datetime] model_config = {"from_attributes": True} diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index dd9ace4..980f375 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -5,9 +5,17 @@ from app.workers.celery_app import celery_app +from sqlalchemy import update +from app.database import engine +from app.models.models import Event + +import asyncio +import datetime +from sqlalchemy import select + @celery_app.task(bind=True, max_retries=3, default_retry_delay=10) def upload_clip_task(self, event_id: int, local_path: str): - """무임승차 영상 클립을 S3에 업로드합니다.""" + """무임승차 영상 클립을 S3에 업로드하고 DB를 업데이트합니다.""" try: s3 = boto3.client( "s3", @@ -15,8 +23,30 @@ def upload_clip_task(self, event_id: int, local_path: str): aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, region_name=settings.AWS_REGION, ) - s3_key = f"clips/event_{event_id}.mp4" + s3_key = f"clips/event_{event_id}_{int(datetime.datetime.now().timestamp())}.mp4" s3.upload_file(local_path, settings.AWS_S3_BUCKET, s3_key) - return {"event_id": event_id, "s3_key": s3_key} + + # 🛡️ 수근 팀장님의 'DB URL 연동 마감' 지침 100% 준수 + clip_url = f"https://{settings.AWS_S3_BUCKET}.s3.{settings.AWS_REGION}.amazonaws.com/{s3_key}" + + # 🧪 비동기 엔진으로 DB 업데이트 (Celery 워커용 브릿지) + async def update_db(): + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + result = await db.execute(select(Event).filter(Event.id == event_id)) + event = result.scalars().first() + if event: + event.clip_url = clip_url + await db.commit() + print(f"🚀 [SUCCESS] Event #{event_id} clip_url updated via Async Bridge") + + asyncio.run(update_db()) + + # 임시 로컬 파일 소각 (정렬 장교 작전) + import os + if os.path.exists(local_path): + os.remove(local_path) + + return {"event_id": event_id, "clip_url": clip_url} except (ClientError, FileNotFoundError) as exc: raise self.retry(exc=exc) diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..5ccf562 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session +testpaths = tests +python_files = test_*.py diff --git a/backend/requirements.txt b/backend/requirements.txt index 431ce45..1e15c83 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,6 +7,7 @@ pydantic==2.10.3 pydantic-settings==2.6.1 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 python-multipart==0.0.19 celery==5.4.0 redis==5.2.1 @@ -15,3 +16,12 @@ websockets==14.1 httpx==0.27.2 email-validator>=2.2.0 python-dotenv==1.0.1 +orjson==3.10.12 + +# 🛡️ Quality & Testing +pytest==8.3.3 +pytest-asyncio==0.24.0 +pytest-cov==6.0.0 +pytest-mock==3.14.0 +aiosqlite==0.20.0 +boto3 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..24acaf4 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,52 @@ +import asyncio +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.pool import StaticPool + +from app.main import app +from app.database import Base, get_db + +# 🛡️ 테스트 전용 인메모리 SQLite 엔진 +SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + +engine = create_async_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session", autouse=True) +async def setup_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +async def db_session(): + async with TestingSessionLocal() as session: + yield session + + +@pytest.fixture +async def client(db_session: AsyncSession): + # Dependency Override: 테스트용 세션 사용 + async def _get_test_db(): + yield db_session + + app.dependency_overrides[get_db] = _get_test_db + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..636f00e --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,69 @@ +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_register_admin(client: AsyncClient): + """[GateGuard] 신규 관리자 사원번호 등록 테스트""" + payload = { + "employee_id": "2026999", + "email": "tester@gateguard.com", + "password": "testpassword123" + } + response = await client.post("/api/auth/register", json=payload) + + assert response.status_code == 201 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +@pytest.mark.asyncio +async def test_register_duplicate_employee_id(client: AsyncClient): + """[GateGuard] 중적 사원번호 등록 차단 테스트""" + payload = { + "employee_id": "2026999", + "email": "tester2@gateguard.com", + "password": "testpassword123" + } + # 첫 번째 등록 + await client.post("/api/auth/register", json=payload) + + # 두 번째 등록 시도 (사원번호 중복) + response = await client.post("/api/auth/register", json=payload) + assert response.status_code == 400 + assert "이미 등록된 사원번호" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_login_success(client: AsyncClient): + """[GateGuard] 사원번호 로그인 성공 테스트""" + # 1. 먼저 가입 + register_payload = { + "employee_id": "2026777", + "email": "login_test@gateguard.com", + "password": "correct_password" + } + await client.post("/api/auth/register", json=register_payload) + + # 2. 로그인 시도 + login_payload = { + "employee_id": "2026777", + "password": "correct_password" + } + response = await client.post("/api/auth/login", json=login_payload) + + assert response.status_code == 200 + assert "access_token" in response.json() + + +@pytest.mark.asyncio +async def test_login_invalid_password(client: AsyncClient): + """[GateGuard] 잘못된 비밀번호 로그인 차단 테스트""" + login_payload = { + "employee_id": "2026777", + "password": "wrong_password" + } + response = await client.post("/api/auth/login", json=login_payload) + + assert response.status_code == 401 + assert "올바르지 않습니다" in response.json()["detail"] diff --git a/backend/tests/test_cameras.py b/backend/tests/test_cameras.py new file mode 100644 index 0000000..cd7a59d --- /dev/null +++ b/backend/tests/test_cameras.py @@ -0,0 +1,66 @@ +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_list_cameras_unauthorized(client: AsyncClient): + """[GateGuard] 비로그인 카메라 목록 조회 차단 테스트""" + response = await client.get("/api/cameras/") + assert response.status_code == 401 + +@pytest.mark.asyncio +async def test_camera_lifecycle(client: AsyncClient): + """[GateGuard] 카메라 등록 및 목록 조회 통합 테스트""" + # 1. 로그인 + register_payload = { + "employee_id": "2029111", + "email": "camera_test@gateguard.com", + "password": "testpassword" + } + await client.post("/api/auth/register", json=register_payload) + login_response = await client.post("/api/auth/login", json={ + "employee_id": "2029111", + "password": "testpassword" + }) + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # 2. 카메라 등록 + camera_payload = { + "location": "강남역 2번 출구", + "station_name": "강남역" + } + create_response = await client.post("/api/cameras/", json=camera_payload, headers=headers) + assert create_response.status_code == 201 + camera_id = create_response.json()["id"] + + # 3. 목록 조회 확인 + list_response = await client.get("/api/cameras/", headers=headers) + assert list_response.status_code == 200 + cameras = list_response.json() + assert any(c["id"] == camera_id for c in cameras) + +@pytest.mark.asyncio +async def test_toggle_camera_status(client: AsyncClient): + """[GateGuard] 카메라 활성화/비활성화 토글 테스트""" + # 1. 로그인 + login_response = await client.post("/api/auth/login", json={ + "employee_id": "2029111", + "password": "testpassword" + }) + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # 2. 카메라 하나 등록 (토글 타겟) + cam_payload = {"location": "테스트 구역", "station_name": "테스트역"} + create_res = await client.post("/api/cameras/", json=cam_payload, headers=headers) + camera_id = create_res.json()["id"] + + # 3. 활성화 상태 토글 (현재 활성 -> 비활성 기대) + toggle_response = await client.patch(f"/api/cameras/{camera_id}/toggle", headers=headers) + assert toggle_response.status_code == 200 + assert toggle_response.json()["is_active"] == False # Default was True + + # 4. 다시 토글 (비활성 -> 활성) + toggle_response = await client.patch(f"/api/cameras/{camera_id}/toggle", headers=headers) + assert toggle_response.status_code == 200 + assert toggle_response.json()["is_active"] == True diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py new file mode 100644 index 0000000..a5863d0 --- /dev/null +++ b/backend/tests/test_events.py @@ -0,0 +1,110 @@ +import pytest +from httpx import AsyncClient + +@pytest.fixture +async def auth_headers(client: AsyncClient): + """테스트용 관리자 토큰 헤더 생성""" + register_payload = { + "employee_id": "2026888", + "email": "event_tester@gateguard.com", + "password": "testpassword123" + } + await client.post("/api/auth/register", json=register_payload) + + login_payload = {"employee_id": "2026888", "password": "testpassword123"} + response = await client.post("/api/auth/login", json=login_payload) + token = response.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_create_event_by_ai(client: AsyncClient, mocker): + """[GateGuard] AI 엔진의 이벤트 생성 테스트""" + # WebSocket 브로드캐스트 모킹 (실제 소켓 연결 없이 테스트) + mock_broadcast = mocker.patch("app.api.websocket.manager.broadcast") + + event_payload = { + "camera_id": 1, + "clip_url": "https://s3.save/test.mp4", + "track_id": 101, + "confidence": 0.95 + } + response = await client.post("/api/events/", json=event_payload) + + assert response.status_code == 201 + data = response.json() + assert data["status"] == "pending" + assert data["confidence"] == 0.95 + + # WebSocket 전파 여부 확인 + mock_broadcast.assert_called_once() + assert mock_broadcast.call_args[0][0]["type"] == "NEW_EVENT" + + +@pytest.mark.asyncio +async def test_list_events_with_auth(client: AsyncClient, auth_headers): + """[GateGuard] 이벤트 목록 조회 테스트 (인증 필요)""" + response = await client.get("/api/events/", headers=auth_headers) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.asyncio +async def test_update_event_status_dispatch(client: AsyncClient, auth_headers, mocker): + """[GateGuard] 이벤트 지휘권 행사(상태 변경) 테스트""" + mock_broadcast = mocker.patch("app.api.websocket.manager.broadcast") + + # 1. 테스트용 이벤트 먼저 생성 + create_resp = await client.post("/api/events/", json={"camera_id": 1}) + event_id = create_resp.json()["id"] + + # 2. 상태 업데이트 (조치 완료) + update_payload = {"status": "confirmed"} + response = await client.patch(f"/api/events/{event_id}/status", json=update_payload, headers=auth_headers) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "confirmed" + assert data["handled_by"] is not None # 조치한 사원 ID 각인 확인 + assert data["handled_at"] is not None # 조치 시각 확인 + + # 실시간 상태 변경 전파 확인 + assert mock_broadcast.called + assert mock_broadcast.call_args[0][0]["type"] == "EVENT_STATUS_UPDATED" + + +@pytest.mark.asyncio +async def test_update_event_not_found(client: AsyncClient, auth_headers): + """[GateGuard] 존재하지 않는 사건 처리 시도 시 404 차단 테스트""" + response = await client.patch("/api/events/9999/status", json={"status": "confirmed"}, headers=auth_headers) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_list_events_filtered(client: AsyncClient, auth_headers): + """[GateGuard] 이벤트 필터링 조회 테스트""" + # 1. 특정 카메라 이벤트 생성 + await client.post("/api/events/", json={"camera_id": 99, "status": "pending"}) + + # 2. 필터링 조회 + response = await client.get("/api/events/?camera_id=99", headers=auth_headers) + assert response.status_code == 200 + events = response.json() + assert len(events) >= 1 + assert all(e["camera_id"] == 99 for e in events) + + +@pytest.mark.asyncio +async def test_create_event_with_celery_delay(client: AsyncClient, mocker): + """[GateGuard] AI 감지 시 S3 업로드 비동기 작업 위임 확인 테스트""" + mock_task = mocker.patch("app.api.events.upload_clip_task.delay") + mocker.patch("app.api.websocket.manager.broadcast") + + event_payload = { + "camera_id": 1, + "clip_url": "s3://test-clip.mp4" + } + await client.post("/api/events/", json=event_payload) + + # Celery 태스크가 딜레이와 함께 호출되었는지 확인 + assert mock_task.called diff --git a/backend/tests/test_events_dispatch.py b/backend/tests/test_events_dispatch.py new file mode 100644 index 0000000..9c93c8c --- /dev/null +++ b/backend/tests/test_events_dispatch.py @@ -0,0 +1,48 @@ +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_update_event_status_unauthorized(client: AsyncClient): + """[GateGuard] 비로그인 사건 조치 차단 테스트""" + response = await client.patch("/api/events/1/status", json={"status": "confirmed"}) + assert response.status_code == 401 + +@pytest.mark.asyncio +async def test_update_nonexistent_event(client: AsyncClient): + """[GateGuard] 존재하지 않는 사건 조치 시도(404) 테스트""" + # 1. 로그인 + register_payload = { + "employee_id": "2027999", + "email": "dispatch_test@gateguard.com", + "password": "testpassword" + } + await client.post("/api/auth/register", json=register_payload) + login_response = await client.post("/api/auth/login", json={ + "employee_id": "2027999", + "password": "testpassword" + }) + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # 2. 존재하지 않는 ID로 조치 시도 + response = await client.patch("/api/events/9999/status", + json={"status": "confirmed"}, + headers=headers) + assert response.status_code == 404 + +@pytest.mark.asyncio +async def test_update_event_invalid_status(client: AsyncClient): + """[GateGuard] 올바르지 않은 상태 값(Validation) 테스트""" + # 1. 로그인 + login_response = await client.post("/api/auth/login", json={ + "employee_id": "2027999", + "password": "testpassword" + }) + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # 2. 잘못된 상태 전송 + response = await client.patch("/api/events/9999/status", + json={"status": "INVALID"}, + headers=headers) + assert response.status_code == 422 # Pydantic Validation Error diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 0000000..2cc9dcd --- /dev/null +++ b/backend/tests/test_notifications.py @@ -0,0 +1,45 @@ +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import Notification, Admin + +@pytest.fixture +async def auth_headers(client: AsyncClient): + """테스트용 관리자 토큰 헤더 생성""" + register_payload = { + "employee_id": "2026888", + "email": "noti_tester@gateguard.com", + "password": "testpassword123" + } + await client.post("/api/auth/register", json=register_payload) + login_payload = {"employee_id": "2026888", "password": "testpassword123"} + response = await client.post("/api/auth/login", json=login_payload) + token = response.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + +@pytest.mark.asyncio +async def test_get_notifications_with_data(client: AsyncClient, auth_headers, db_session: AsyncSession): + # n1: read_at is None + n1 = Notification(event_id=1, read_at=None) + db_session.add(n1) + await db_session.commit() + await db_session.refresh(n1) # Ensures ID is bound + + # Test list + response = await client.get("/api/notifications/", headers=auth_headers) + assert response.status_code == 200 + + # Test read success path (39-46) + read_resp = await client.patch(f"/api/notifications/{n1.id}/read", headers=auth_headers) + assert read_resp.status_code == 200 + assert "Success" in read_resp.json()["message"] + +@pytest.mark.asyncio +async def test_mark_all_read_success_path(client: AsyncClient, auth_headers, db_session: AsyncSession): + n = Notification(event_id=1, read_at=None) + db_session.add(n) + await db_session.commit() + + # Test read-all path (54-55) + response = await client.post("/api/notifications/read-all", headers=auth_headers) + assert response.status_code == 200 diff --git a/backend/tests/test_s3.py b/backend/tests/test_s3.py new file mode 100644 index 0000000..f844be0 --- /dev/null +++ b/backend/tests/test_s3.py @@ -0,0 +1,65 @@ +import pytest +from unittest.mock import MagicMock, patch +from app.core.s3 import S3Client, s3_client +from botocore.exceptions import ClientError, NoCredentialsError + +@pytest.fixture +def mock_boto3_client(): + with patch("app.core.s3.S3Client.__init__", return_value=None): + with patch("boto3.client") as mock: + client = MagicMock() + mock.return_value = client + s3 = S3Client() + s3.bucket_name = "test-bucket" + s3.region = "us-east-1" + s3.is_configured = True + s3.client = client + yield s3, client + +def test_s3_singleton_instance(): + from app.core.s3 import s3_client as c1 + from app.core.s3 import s3_client as c2 + assert c1 is c2 + +@pytest.mark.asyncio +async def test_upload_file_real_mode(mock_boto3_client): + s3, mock_boto = mock_boto3_client + mock_boto.upload_file.return_value = None + url = await s3.upload_file("local.mp4", "s3.mp4") + assert "s3.mp4" in url + +@pytest.mark.asyncio +async def test_upload_file_file_not_found(mock_boto3_client): + s3, mock_boto = mock_boto3_client + mock_boto.upload_file.side_effect = FileNotFoundError() + url = await s3.upload_file("missing.mp4", "s3.mp4") + assert url == "" + +@pytest.mark.asyncio +async def test_upload_file_no_credentials(mock_boto3_client): + s3, mock_boto = mock_boto3_client + mock_boto.upload_file.side_effect = NoCredentialsError() + url = await s3.upload_file("local.mp4", "s3.mp4") + assert url == "" + +@pytest.mark.asyncio +async def test_upload_file_client_error(mock_boto3_client): + s3, mock_boto = mock_boto3_client + mock_boto.upload_file.side_effect = ClientError({"Error": {"Code": "500", "Message": "AWS Error"}}, "upload_file") + url = await s3.upload_file("local.mp4", "s3.mp4") + assert url == "" + +def test_get_presigned_url_client_error(mock_boto3_client): + s3, mock_boto = mock_boto3_client + mock_boto.generate_presigned_url.side_effect = ClientError({"Error": {"Code": "500", "Message": "AWS Error"}}, "op") + url = s3.get_presigned_url("test.mp4") + assert url == "" + +@pytest.mark.asyncio +async def test_s3_init_exception(): + # Test connection exception in __init__ + with patch("boto3.client", side_effect=Exception("Conn error")): + with patch("app.core.config.settings") as mock_settings: + mock_settings.AWS_ACCESS_KEY_ID = "valid-key" + s3 = S3Client() + assert s3.is_configured is False diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py new file mode 100644 index 0000000..8787909 --- /dev/null +++ b/backend/tests/test_tasks.py @@ -0,0 +1,60 @@ +import pytest +from unittest.mock import MagicMock, patch, AsyncMock +from app.workers.tasks import upload_clip_task +from app.models.models import Event + +@pytest.fixture +def mock_s3_client(): + with patch("boto3.client") as mock: + client = MagicMock() + mock.return_value = client + yield client + +@pytest.fixture +def mock_db_session(): + with patch("app.database.AsyncSessionLocal") as mock_session_class: + mock_session = AsyncMock() + mock_session_class.return_value.__aenter__.return_value = mock_session + yield mock_session + +def test_upload_clip_task_success(mock_s3_client, mock_db_session): + # Mocking OS file operations + with patch("os.path.exists", return_value=True), \ + patch("os.remove", return_value=None): + + # Mocking DB response: event found + mock_event = Event(id=1, clip_url=None) + + # scalars().first() sequence mocking + mock_scalars = MagicMock() + mock_scalars.first.return_value = mock_event + mock_result = MagicMock() + mock_result.scalars.return_value = mock_scalars + mock_db_session.execute.return_value = mock_result + + # Execute task (Mocking datetime to stabilize key) + with patch("datetime.datetime") as mock_dt: + mock_dt.now.return_value.timestamp.return_value = 123456789 + result = upload_clip_task(1, "/tmp/test.mp4") + + assert result["event_id"] == 1 + assert "event_1_123456789" in result["clip_url"] + + # Verify S3 upload + mock_s3_client.upload_file.assert_called_once() + + # Verify DB update + mock_db_session.commit.assert_called_once() + assert mock_event.clip_url is not None + +def test_upload_clip_task_retry_on_client_error(mock_s3_client): + from botocore.exceptions import ClientError + # Mocking S3 to raise error + mock_s3_client.upload_file.side_effect = ClientError({"Error": {"Code": "500", "Message": "AWS Error"}}, "upload_file") + + # We expect a retry + with patch.object(upload_clip_task, "retry") as mock_retry: + mock_retry.side_effect = Exception("Retrying...") + with pytest.raises(Exception, match="Retrying..."): + upload_clip_task(1, "/tmp/test.mp4") + mock_retry.assert_called_once() diff --git a/backend/tests/test_websocket.py b/backend/tests/test_websocket.py new file mode 100644 index 0000000..ddf8b60 --- /dev/null +++ b/backend/tests/test_websocket.py @@ -0,0 +1,51 @@ +import asyncio +import pytest +from unittest.mock import AsyncMock, patch +from fastapi.testclient import TestClient +from app.main import app +from app.api.websocket import ConnectionManager, manager as global_manager + +@pytest.fixture(autouse=True) +def cleanup_manager(): + global_manager._connections = [] + yield + +def test_websocket_endpoint_flow(): + # Use TestClient (Sync) for testing the route itself (39-44 in websocket.py) + client = TestClient(app) + # Patch manager to prevent side effects in tests + with patch("app.api.websocket.manager.connect", new_callable=AsyncMock) as mock_connect, \ + patch("app.api.websocket.manager.disconnect") as mock_disconnect: + + with client.websocket_connect("/ws/events") as websocket: + # Connect method should be called (39) + mock_connect.assert_called_once() + + # Since while True (41) waits for message, we send one to trigger it + websocket.send_text("ping") + + # Disconnect should be called (44) + mock_disconnect.assert_called_once() + +def test_manager_connect_disconnect(): + async def run_test(): + mock_ws = AsyncMock() + custom_manager = ConnectionManager() + await custom_manager.connect(mock_ws) + assert mock_ws in custom_manager._connections + + custom_manager.disconnect(mock_ws) # (18) + assert mock_ws not in custom_manager._connections + return True + assert asyncio.run(run_test()) + +def test_manager_broadcast_error_path(): + async def run_test(): + mock_ws = AsyncMock() + mock_ws.send_text.side_effect = Exception("failed") + custom_manager = ConnectionManager() + await custom_manager.connect(mock_ws) + await custom_manager.broadcast({"test": "data"}) # (29-31) + assert mock_ws not in custom_manager._connections + return True + assert asyncio.run(run_test()) diff --git a/docker-compose.yml b/docker-compose.yml index 6e672f4..0415e51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: condition: service_healthy volumes: - ./backend:/app + - ./scripts:/app/scripts worker: build: ./backend @@ -51,6 +52,7 @@ services: condition: service_healthy volumes: - ./backend:/app + - ./scripts:/app/scripts volumes: pgdata: diff --git a/docs/admin/GateGuard_Weekly_Report_20260330.md b/docs/admin/GateGuard_Weekly_Report_20260330.md new file mode 100644 index 0000000..04cf027 --- /dev/null +++ b/docs/admin/GateGuard_Weekly_Report_20260330.md @@ -0,0 +1,49 @@ +# 🛰️ GateGuard : 주간 기술 통합 및 Milestone v1.0 달성 보고서 + +**📅 일시**: 2026-03-30 (월) +**👤 작성**: 백엔드 팀장 조수근 +**🚩 목표**: 인프라 구축 및 백엔드 코어 모듈 최종 점검 (M2 전환 준비) + +--- + +## 🏗️ 1. 현재 시스템 구축 현황 (Status: ACTIVE ✅) + +> "GateGuard 시스템의 핵심 인프라가 클라우드 환경에서 24시간 정상 가동 중입니다." + +| 항목 | 상세 내용 | 상태 | +| :--- | :--- | :---: | +| **클라우드 서버** | AWS EC2 (Ubuntu 24.04 LTS) | **운영 중** 🟢 | +| **API 문서 주소** | `http://15.135.92.86:8000/docs` | **정상 접속** 🟢 | +| **배포 자동화** | GitHub Actions CI/CD Pipeline | **구축 완료** 🟢 | +| **보안 환경** | OAuth2 기반 JWT 인증 & SSH Key 보안 적용 | **보안 유지** 🔒 | + +--- + +## 🛡️ 2. 주요 기술 성과 (Milestone v1.0) + +### 💠 백엔드 코어 모듈 통합 +- **실시간 통신 엔진**: 개찰구 무임승차 감지 시 즉각적인 알림 전송을 위한 **WebSocket 브로드캐스트** 로직 완공. +- **데이터 저장 체계**: PostgreSQL 기반 TimescaleDB(시계열) 최적화 테이블 설계 및 Alembic 마이그레이션 체계 도입. +- **인증 및 보안**: JWT(Json Web Token) 기반의 권한 관리(RBAC) 시스템 구축 및 주요 라우트 보호 처리. + +--- + +## 🚀 3. 향후 주요 과제 (M1 최종 보완 및 M2 실행 계획) + +### 🔹 [인프라 파트] 최태양 담당자 +- **Nginx 설정 고도화**: 리버스 프록시 적용을 통한 서버 보안 강화 및 도메인 연결 시 HTTPS(SSL) 즉시 연계 준비. +- **운영 안정화**: Docker Log Rotation 설정을 통한 서버 디스크 자원 고갈 사전 방지. + +### 🔹 [데이터베이스 파트] 김민지 담당자 +- **기초 데이터(Seed) 주입**: `seed.py` 작성을 통해 프로젝트 초기 테스트를 위한 카메라 및 관리자 데이터 삽입. +- **전체 시나리오 검증**: Swagger UI를 활용한 로그인부터 이벤트 생성까지의 전 과정 통합 테스트 진행. + +### 🔹 [인공지능 파트] 윤효정 담당자 +- **추론 파이프라인 연동**: `inference.py` 탐지 모델의 결과 값을 백엔드 API와 유기적으로 연동하는 작업 착수. +- **S3 스토리지 활용**: 확보된 S3 버킷 권한을 바탕으로 감지 영상 클립의 실시간 업로드 기능 구현. + +--- +

+ GateGuard Team - "Innovative Subway Security Solutions"
+ Copyright © 2026 GateGuard. All rights reserved. +

diff --git a/docs/admin/TODO_Milestone_1.md b/docs/admin/TODO_Milestone_1.md new file mode 100644 index 0000000..0859c20 --- /dev/null +++ b/docs/admin/TODO_Milestone_1.md @@ -0,0 +1,62 @@ +# GateGuard — Milestone 1.0 (Foundation) - 화요일(4/1)까지 목표 + +--- + +## 조수근 (백엔드) 🏆 [Milestone v1.0 완료] +- [x] `backend/.env` 작성 후 `docker-compose up -d` → 서버 정상 실행 확인 (완료: 2026-03-27) +- [x] `http://localhost:8000/docs` 에서 전 엔드포인트 동작 확인 (정상 응답 확인) +- [x] Alembic 세팅 → `alembic upgrade head` 로 테이블 생성 (**완료: 2026-03-27**) +- [x] `get_current_admin` 의존성 함수 작성 → 주요 라우트에 JWT 인증 적용 (**완료: 2026-03-27**) +- [x] 이벤트 생성 시 `manager.broadcast()` 호출 → WebSocket 실시간 전송 연결 (**완료: 2026-03-27**) +- [x] **백엔드 코어 인프라 통합 완료 및 master 병합** 🚀 (완료: 2026-03-27) + +--- + +## 김민지 (DB) 🏆 [Milestone v1.0 완료] +- [x] DB 컨테이너 접속 후 TimescaleDB 확장 활성화 + `events` 하이퍼테이블 변환 (**완료: 2026-03-31**) ✨🏆 +- [x] Alembic `env.py` async 엔진 연결 (조수근과 함께 완료: 2026-03-27) +- [x] `seed.py` 작성 → 관리자 계정 + 테스트 카메라 데이터 삽입 (**완료: 2026-03-31**) 🏎️💨 +- [x] Swagger에서 로그인 → 이벤트 생성까지 흐름 확인 (**완료: 2026-03-31**) 🎯 + +--- + +## 이동근 · 최태양 (인프라) 🚀 [완료] +- [x] AWS EC2 생성 (Ubuntu 24.04 LTS) + 보안 그룹 포트 설정 (22, 80, 8000) (완료: 2026-03-28) +- [x] EC2 서버 환경에 Docker + Docker Compose 설치 완료 +- [x] `docker-compose up -d --build` 실행 → EC2 위에서 백엔드 앱 가동 확인 +- [x] `http://15.135.92.86:8000/docs` 접속 확인 및 팀 공유 성공 +- [x] **GitHub Actions CI/CD 파이프라인 구축 및 자동 배포 연동** (완료: 2026-03-28) +- [x] **[최태양] AWS S3 버킷 생성 및 백엔드 연동용 IAM Key 발급** (완료: 2026-03-30) +- [x] **[최태양] Nginx 리버스 프록시 + Let's Encrypt HTTPS(SSL) 적용** (M2 이관 완료) +- [x] **[최태양] Docker Log Rotation 설정 (서버 디스크 고갈 방지)** (M2 이관 완료) + +--- + +## 이지현 · 김유진 · 양은혜 (프론트엔드) 🏆 [완료] +- [x] Vite + React + TypeScript + Tailwind + Shadcn UI 초기 세팅 (**완료: 2026-03-30**) +- [x] axios 인스턴스 + React Router 기본 설정 (**완료: 2026-03-30**) +- [x] 로그인 페이지 → `POST /api/auth/login` 연결 + 토큰 저장 (**완료: 2026-03-30**) +- [x] 로그인 후 대시보드 레이아웃 (사이드바 + 헤더) 구성 (**완료: 2026-03-30**) + +--- + +## 윤효정 (AI) 🚧 [M2 전격 이관] +- [ ] **[M2 이관]** `pip install ultralytics supervision opencv-python httpx` 설치 +- [ ] **[M2 이관]** `python ai/inference.py` 실행 → 웹캠에서 사람 감지 + 바운딩 박스 확인 +- [ ] **[M2 이관]** 같은 사람 이동 시 tracker ID 유지되는지 확인 +- [ ] **[M2 이관]** 얼굴 블러 동작 확인 +- [ ] **[M2 이관]** 테스트 영상 기준으로 Line Crossing 좌표 맞게 조정 + +> **사유**: 테스트 영상 부재 및 M2 "Blitz Integration" 집중을 위한 전략적 선후관계 조정 (2026-03-31) + +--- + +## 브랜치 인프라 (v1.0 통합 완료) +``` +master (통합 거점) 🛡️ +├── feature/조수근-backend-complete-v1 <-- 🏁 완료 +├── feature/김민지-db-setup (PR #10) <-- 🏁 완료 +├── feature/이동근-infra-cicd <-- 🏁 완료 +├── feature/이지현-dashboard-ui <-- 🏁 완료 +└── feature/윤효정-yolo-pipeline <-- 🚧 M2 집중 타겟 +``` diff --git a/docs/admin/TODO_Milestone_2.md b/docs/admin/TODO_Milestone_2.md new file mode 100644 index 0000000..4b5b82e --- /dev/null +++ b/docs/admin/TODO_Milestone_2.md @@ -0,0 +1,67 @@ +# 🛰️ GateGuard — Milestone 2.0 (3/31 ~ 4/10) - “High-Intensity Blitz” 목표 + +> **전략적 지침**: 시험 기간(4월 중순) 동안 개발이 일시 중단될 것을 대비하여, 시스템의 모든 핵심 기능을 “완전 자동화” 및 “통합 완료”하여 서비스가 자가 가동되도록 함. (High Workload & Goal-Oriented) + +--- + +## 🦁 조수근 (백엔드 · DB · PM 총괄) 🏗️ + +- [x] **AI 엔진 통합 및 마스터 브랜치 동기화**: 윤효정 팀원 PR(#12) 병합 및 로컬 환경 동기화 완료 🚀 +- [x] **얼굴 비식별화 모델(`yolov11n-face.pt`) 세팅**: AI 구동을 위한 전용 모델 다운로드 및 경로 배치 완료 💎 +- [x] **S3 실체화 (AI 연동)**: 현재 로컬 경로인 `clip_url`을 실제 S3 업로드 URL로 교체 📸 +- [x] **S3 영상 트리밍 & 자동 업로드 파이프라인 완성**: AI 감지 시점 앞뒤 10초 영상 절삭 → 비동기(Celery) S3 업로드 → DB URL 연동 마감 📸 +- [x] **사원번호 기반 통합 인증 서버 완비**: `/api/auth/register` (사원번호 + 비밀번호), `/api/auth/find-pw` 등 핵심 보안 로직 완성 🔐 +- [x] **실시간 알림 디스패처 구축**: WebSocket 브로드캐스트 엔진 완성 📡 +- [x] **백엔드 유닛 테스트 커버리지 80% 달성**: 현재 81%. 감지 로직 및 인증, 알림 API 집중 테스트 코드 작성 완료 🎯 🎊 +- [x] **S3 업로드 시뮬레이션 및 인프라 연동 규격 확립**: `boto3` 기반 업로드 프로토타입 작성 및 기술 문서(AI/DB 가이드) v2.0 최신화 완료 🌐 +- [x] **통계 View & Materialized View 구축**: 대시보드(ECharts) 로딩 속도 향상을 위한 시간대별/역별 Continuous Aggregate(TimescaleDB) 반영 완료 📈 +- [x] **DB Schema 정규화 최종 마감**: 관리자 권한(Role), 활성화 상태 필드 추가 및 90일 데이터 보관 정책(Retention Policy) 반영 완료 🗄️ +- [x] **High-Density Seed Data 생산**: 다양한 무임승차 시나리오 테스트를 위한 5,000건의 시각화 데이터 삽입 및 대시보드 부하 테스트 완료 📊 + +### 인프라 및 성능 최적화 (Infrastructure & Performance) + +- [x] **TimescaleDB 데이터 압축 정책 반영**: 7일 경과 데이터 90% 이상 압축 (스토리지 절약) 🗜️ +- [x] **고속 JSON 직렬화 엔진(`orjson`) 도입**: FastAPI API 응답 속도 극대화 ⚡ +- [x] **DB 복합 인덱스 최적화**: 특정 카메라 및 타입별 최신 이벤트 조회 성능 가속 🚀 + +--- + +## 🎞️ 김민지 (데이터 수집 전략) 🕵️‍♂️ [전략적 자산 확보] + +- [ ] **무임승차 시나리오별 영상 데이터셋 확보**: 뒤따르기, 점프, 비상문 이용 등 유형별 10건 이상의 고화질 테스트 영상 수집 및 정제 🎥 (우선 과제) + +--- + +## 🏗️ 이동근 · 최태양 (인프라) 🏰 [무인 가동 보장] + +- [x] **Docker Log Rotation & Disk 관리**: 디스크 고갈 방지를 위한 10MB 기준 로그 순환 및 임시 파일 자동 삭제 설정 완료 (PR #14) 🐳 +- [x] **PostgreSQL(TimescaleDB) 자동 백업 체계**: S3를 이용한 일일 DB 덤프 자동화 및 복구 스크립트 검증 완료 (PR #14) 🗄️🛡️ +- [x] **Nginx Reverse Proxy & SSL 완비**: https://gateguardsystems.com 도메인 연동 및 Certbot SSL 인증서 발급 완료 🔐 +- [x] **서버 리소스 자가 모니터링**: CPU/Memory 임계치 초과 시 팀장에게 즉시 알림 발송 설정 완료 (scripts/monitor.sh 반영) 📊 + +--- + +## 🧠 윤효정 (AI 파이프라인) 🤖 [이관 과업 및 지능형 고도화] + +- [x] **[M1 이관] AI 코어 기동**: Ultralytics/Supervision 등 라이브러리 구축 및 `ai/inference.py` 첫 구동 성공 🚀 +- [x] **[M1 이관] 트래커 및 블러 안정화**: ByteTrack(v0.27.0) 도입 및 얼굴 비식별화 자동화 파이프라인 완성 🎥 +- [ ] **테스트 영상 데이터셋 튜닝**: 민지님이 확보한 영상을 활용한 케이스별 탐지 정확도 정밀 튜닝 🎬 +- [ ] **동작 인식(Action Recognition) 프로토타입**: 단순 객체 탐지를 넘어 무임승차 '동작'을 구별하는 행동 분석 모델 베타 탑재 🛰️ +- [ ] **Line Crossing 좌표 정밀 조정**: 확보된 영상을 기준으로 무임승차 판단을 위한 가상 경계선 좌표 최적화 🎥 + +--- + +## 🏎️ 이지현 · 김유진 · 양은혜 (프론트엔드) 🏆 [인간 중심 UI/UX] + +- [ ] **CCTV 타임라인 통합 UI 개발**: 객체 등장 시점부터 시간 흐름에 따라 동선을 추적하고 재생하는 타격 중심 UI 완성 (혁신 과제) 📺 +- [ ] **전체 기간 이벤트 통합 검색 및 필터링**: 날짜별, 유형별, 카메라별 무임승차 이력 전체 조회 및 영상 다운로드 연동 🔔 +- [ ] **실시간 통계 차트 (ECharts)**: WebSocket 수신 데이터를 실시간으로 반영하는 역별 현황 대시보드 고도화 📊 +- [ ] **다크 모드 & 고해상도 디자인 폴리싱**: 대형 관제 모니터에 최적화된 시인성 높은 프리미엄 UI 마감 🛡️ + +--- + +## 🏁 Milestone 2.0 성공 기준 (Definition of Perfection) + +1. [ ] **Self-Sufficient**: 사람이 개입하지 않아도 AI 탐지 -> 영상 저장 -> 통계 반영까지 정지 없이 작동함. +2. [ ] **Integrated**: 프론트엔드의 세련된 UI와 백엔드의 단단한 API가 완전히 하나로 동작함. +3. [ ] **Exam-Proof**: 시험 공부에 전념하는 동안, 서버는 24시간 한 번도 꺼지지 않고 데이터를 수집함. diff --git a/docs/artifacts/dashboard_screenshot.png b/docs/artifacts/dashboard_screenshot.png new file mode 100644 index 0000000..19dd722 Binary files /dev/null and b/docs/artifacts/dashboard_screenshot.png differ diff --git a/docs/technical/AI_BACKEND_INTEGRATION_GUIDE.md b/docs/technical/AI_BACKEND_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..aa8c2d0 --- /dev/null +++ b/docs/technical/AI_BACKEND_INTEGRATION_GUIDE.md @@ -0,0 +1,71 @@ +# 🛰️ GateGuard — AI-Backend API Integration Guide (v2.1) + +> **전술적 목적**: AI 추론 모델(`inference.py`)에서 감지된 무임승차 이벤트를 백엔드 서버로 전송하기 위한 '공식 통신 규격'입니다. 효정님의 최신 AI 파이프라인(v0.27.0)과 수근 팀장의 보안 지침이 통합된 v2.1 버전입니다. + +--- + +## 🚀 1. 핵심 엔드포인트: 이벤트 생성 (Create Event) + +AI 모델은 사람 감지 및 무임승차 판단 시 즉시 아래 API를 호출하여 이벤트를 기록합니다. + +- **API 도메인**: `https://gateguardsystems.com` +- **Method**: `POST` +- **Authentication**: Bearer Token (수근 팀장에게 발급 문의) +- **감지 데이터 전송**: `POST /api/events/` + +### 💠 Request Payload (JSON) + +AI 파이프라인에서 다음의 규격에 맞춰 JSON 데이터를 전송합니다. + +```json +{ + "camera_id": 1, + "clip_url": "https://gateguard-clips.s3.ap-northeast-2.amazonaws.com/events/clip_20260331_1020.mp4", + "track_id": 42, + "confidence": 0.985 +} +``` + +| 필드명 | 타입 | 필수 여부 | 설명 | +| :--- | :--- | :---: | :--- | +| **camera_id** | `int` | **필수** | 감지된 지하철역 개찰구 카메라의 고유 ID | +| **clip_url** | `str` | 선택 | S3에 업로드된 증빙 영상 클립의 URL | +| **track_id** | `int` | 선택 | ByteTrack에 의해 부여된 객체의 고유 추적 ID | +| **confidence** | `float` | 선택 | AI 추론 모델의 감지 신뢰도 (0.0 ~ 1.0) | + +--- + +## 📸 2. S3 영상 업로드 가이드 (Core S3 Client) + +백엔드 코어에 공통 S3 클라이언트가 구현되어 있습니다. AI 연동 팀(효정)은 이를 활용하여 업로드를 수행할 수 있습니다. + +- **파일 위치**: `backend/app/core/s3.py` +- **사용 방법**: `s3_client.upload_file(local_path, s3_name)` 호출 시 URL 즉시 반환 + +> [!TIP] +> **시뮬레이션 모드 지원**: `.env`에 AWS 키가 설정되지 않은 개발 환경에서는 자동으로 **Simulation Mode**가 활성화됩니다. 이 경우 가상의 S3 URL을 반환하므로 로직 테스트를 끊김 없이 진행할 수 있습니다. + +--- + +## 🛡️ 3. 실시간 알림 메커니즘 (WebSocket Flow) + +AI가 위 API를 호출하는 즉시, 백엔드 서버는 다음 프로세스를 자동으로 수행합니다. + +1. **DB 기록**: `events` 테이블에 데이터 즉시 영속화 (TimescaleDB 최적화) +2. **WebSocket Broadcast**: 대시보드 관리자에게 `/ws/notifications` 채널로 실시간 알림 패킷 전송 +3. **Push Notification**: 역무원 모바일 앱으로 푸시 알림 트리거 (준비 중) + +--- + +## 🛠️ 4. AI 파이프라인 구동 사양 (AI Specs) + +효율적인 협업을 위해 다음 기술 사양을 반드시 준수합니다. + +1. **의존성 설치**: 반드시 `pip install -r ai/requirements.txt` 명령어로 전용 패키지를 설치합니다. +2. **추적 엔진**: `supervision` v0.27.0 이상을 사용하며, 메서드 명은 `sv.ByteTrack()`을 사용합니다. +3. **비식별화 모델**: 얼굴 탐지를 위해 `ai/yolov11n-face.pt` 경로의 모델을 로드합니다 (미존재 시 `inference.py`가 경고를 띄웁니다). +4. **보안 인증**: 백엔드 API 호출 시 `inference.py` 내의 `generate_master_token()`을 통해 실시간 JWT를 주조하여 헤더에 포함합니다. + +--- + +## 🏁 최종 업데이트 일시: 2026-04-01 15:45 (KST, v2.1) diff --git a/docs/technical/DB_SETUP_GUIDE.md b/docs/technical/DB_SETUP_GUIDE.md new file mode 100644 index 0000000..1c28b84 --- /dev/null +++ b/docs/technical/DB_SETUP_GUIDE.md @@ -0,0 +1,104 @@ +# DB 설정 및 테스트 가이드 + +## 1. 서버 실행 + +```bash +# 프로젝트 루트에서 +docker compose up -d +``` + +## 2. Alembic 마이그레이션 실행 + +```bash +# 기존 테이블 생성 + TimescaleDB 하이퍼테이블 변환 +docker compose exec backend alembic upgrade head +``` + +### 마이그레이션 내역 + +| 리비전 | 설명 | +|--------|------| +| `a4437e459dcf` | 초기 테이블 생성 (admins, cameras, events, notifications) | +| `b5521f8a3c10` | TimescaleDB 확장 활성화 + events 하이퍼테이블 변환 | + +## 3. 시드 데이터 삽입 + +```bash +docker compose exec backend python scripts/db_baseline_seed.py +``` + +### 생성되는 계정 + +| 이메일 | 비밀번호 | 용도 | +|--------|----------|------| +| `admin@gateguard.com` | `admin1234` | 메인 관리자 | +| `station01@gateguard.com` | `station1234` | 역무원 테스트 | + +### 생성되는 카메라 + +| 역명 | 위치 | 상태 | +| :--- | :--- | :--- | +| 광교역 | 개찰구 1번 게이트 | 활성 | +| 광교역 | 개찰구 2번 게이트 | 활성 | +| 광교중앙역 | 개찰구 1번 게이트 | 활성 | +| 광교중앙역 | 개찰구 2번 게이트 | 비활성 | +| 상현역 | 비상문 출구 | 활성 | + +## 4. Swagger 흐름 테스트 + +브라우저에서 `http://localhost:8000/docs` 접속 후 아래 순서대로 진행: + +### Step 1: 로그인 + +1. `POST /api/auth/login` 클릭 +2. Request body 입력: + + ```json + { + "email": "admin@gateguard.com", + "password": "admin1234" + } + ``` + +3. Execute → `access_token` 값 복사 + +### Step 2: 토큰 등록 + +1. 페이지 상단 **Authorize** 버튼 클릭 +2. Value에 `Bearer {복사한_토큰}` 입력 후 Authorize + +### Step 3: 카메라 목록 확인 + +1. `GET /api/cameras` → Execute +2. 시드 데이터로 삽입한 5개 카메라가 보이면 성공 + +### Step 4: 이벤트 생성 + +1. `POST /api/events` 클릭 +2. Request body: + + ```json + { + "camera_id": 1, + "clip_url": "https://example.com/test-clip.mp4", + "track_id": 42, + "confidence": 0.95 + } + ``` + +3. Execute → 201 응답 확인 + +### Step 5: 이벤트 조회 + +1. `GET /api/events` → Execute +2. 방금 생성한 이벤트가 목록에 표시되면 전체 흐름 정상 + +## 5. TimescaleDB 확인 (선택) + +```bash +docker compose exec db psql -U gateguard -c "\dx" +# timescaledb 확장이 목록에 있으면 OK + +docker compose exec db psql -U gateguard -c "SELECT * FROM timescaledb_information.hypertables;" +# events 테이블이 하이퍼테이블로 표시되면 OK +``` diff --git "a/docs/technical/GateGuard_\352\270\260\353\212\245\353\252\205\354\204\270\354\204\234_\355\224\214\353\241\234\354\232\260\354\260\250\355\212\270_\355\206\265\355\225\251.html" "b/docs/technical/GateGuard_\352\270\260\353\212\245\353\252\205\354\204\270\354\204\234_\355\224\214\353\241\234\354\232\260\354\260\250\355\212\270_\355\206\265\355\225\251.html" new file mode 100644 index 0000000..879e788 --- /dev/null +++ "b/docs/technical/GateGuard_\352\270\260\353\212\245\353\252\205\354\204\270\354\204\234_\355\224\214\353\241\234\354\232\260\354\260\250\355\212\270_\355\206\265\355\225\251.html" @@ -0,0 +1,570 @@ + + + + + +GateGuard 기능명세서 및 플로우차트 + + + + + + +
+ +
기능명세서 및 프론트엔드 API 연동 가이드
+
담당: 이지현 (프론트팀장)  |  작성: 이동근 (인프라)  |  2026.03.30
+
경기대학교 AI컴퓨터공학부 캡스톤디자인 2026
+
+ + +
+ 🌐 API 서버: http://15.135.92.86:8000 + 📖 Swagger: http://15.135.92.86:8000/docs + ⚡ WebSocket: ws://15.135.92.86:8000/ws/events + 🔐 인증: Authorization: Bearer {token} +
+ +
+ + + + +
+
PART 01
+

기능명세서

+

피그마 화면 기준 각 화면별 기능 및 API 연동 정의

+
+ + +
01. 로그인 화면
+ + + + + + + +
기능상세 설명연동 API구현 여부
로그인이메일 + 비밀번호 입력 후 Sign In 클릭POST /api/auth/login구현완료
비밀번호 기억로컬스토리지에 토큰 저장하여 로그인 상태 유지-프론트 자체
토큰 관리로그인 성공 시 JWT 저장 및 이후 요청에 자동 포함POST /api/auth/login구현완료
+ + +
02. 대시보드 화면
+ + + + + + + + + + + +
기능상세 설명연동 API구현 여부
통계 카드오늘 발생 / 확인 대기 / 처리 완료 / 오탐 건수GET /api/events/stats미구현
최신 알림 리스트최신 이벤트 목록 표시, 클릭 시 상세보기 이동GET /api/events/?limit=20구현완료
실시간 알림무임승차 감지 시 즉시 리스트 상단에 추가WebSocket /ws/events구현완료
구간별 현황역별/게이트별 알림 건수 우측 패널 표시GET /api/events/stats/by-camera미구현
최근 오탐 신고최근 오탐 처리 항목 우측 하단 표시GET /api/notifications/구현완료
상세보기 버튼알림 항목 클릭 시 상세보기 이동GET /api/events/{id}미구현
오탐신고 버튼알림 항목에서 오탐신고창으로 이동POST /api/events/{id}/false-alarm미구현
+ + +
03. 상세보기 화면
+ + + + + + + + + + +
기능상세 설명연동 API구현 여부
사건사진S3에 저장된 영상 클립 또는 캡처 이미지 표시EventResponse.clip_url구현완료
감지 설명AI가 분석한 무임승차 유형 및 용의자 설명EventResponse 데이터 활용구현완료
위치/시각역 이름, 게이트 번호, 발생 시각 표시CameraResponse + EventResponse구현완료
처리완료이벤트 상태를 confirmed로 변경PATCH /api/events/{id}/status구현완료
오탐신고오탐신고창으로 이동POST /api/events/{id}/false-alarm미구현
이벤트 조회단건 이벤트 상세 정보 로드GET /api/events/{id}미구현
+ + +
04. 오탐신고 화면
+ + + + + + + + +
기능상세 설명연동 API구현 여부
사건사진동일 이벤트 사진 재표시EventResponse.clip_url구현완료
사유 선택정상태그 인식 지연 / 시스템 오류 / 기계 결함 / 기타프론트 UI프론트 자체
신고 완료선택 사유와 함께 오탐 API 호출 후 상태 변경POST /api/events/{id}/false-alarm미구현
취소신고 취소 후 이전 화면 복귀-프론트 자체
+ + +
05. 감지 로그 화면
+ + + + + + + + + + + +
기능상세 설명연동 API구현 여부
전체 이력 조회전체 기간 무임승차 감지 이력 최신순 목록 표시GET /api/events/구현완료
날짜별 필터링시작일 ~ 종료일 범위로 이벤트 조회GET /api/events/?date_from=&date_to=미구현
유형별 필터링무임승차 유형별 필터링GET /api/events/?type=미구현
카메라별 필터링특정 역/게이트 카메라별 이벤트 필터링GET /api/events/?camera_id=구현완료
상태별 필터링처리완료 / 확인대기 / 오탐 등 상태별 필터링GET /api/events/?status=구현완료
영상 다운로드감지된 영상 클립 S3 URL을 통해 다운로드EventResponse.clip_url 활용구현완료
페이지네이션대량 데이터 페이지 단위 조회GET /api/events/?limit=&offset=미구현
+ + +
구현 현황 요약
+ + + + + + + + + + + + + + +
구분기능상태비고
인증로그인 / JWT 발급구현완료
대시보드최신 알림 리스트 / 실시간 알림구현완료
대시보드통계 카드 / 구간별 알림 현황미구현수근님 구현 요청 필요
상세보기이벤트 단건 조회미구현수근님 구현 요청 필요
상세보기처리완료 상태 변경구현완료
오탐신고오탐 신고 접수미구현수근님 구현 요청 필요
감지 로그전체 이력 / 카메라·상태 필터링구현완료
감지 로그날짜·유형·페이지네이션미구현수근님 구현 요청 필요
카메라카메라 목록 조회구현완료
알림알림 목록 / 읽음 처리구현완료
+
⚠ 미구현 API 4개는 백엔드 팀장(조수근)에게 M2 일정 내 구현 요청이 필요합니다.
우선순위: 이벤트 단건 조회 > 오탐 신고 > 통계 API
+ +
+ + + + +
+
PART 02
+

시스템 플로우차트

+

GateGuard 핵심 흐름 시각화

+
+ + +
01. 전체 시스템 흐름
+
+ + + + + + + +CCTV 영상 입력실시간 스트림 + + +비식별화 처리얼굴 감지 및 블러 (OpenCV) + + +AI 탐지 (YOLOv11 + ByteTrack)다중 객체 감지 및 ID 추적 + + +Supervision 분석라인 크로싱 / 구역 통과 감지 + + +무임승차 판정 (FastAPI)게이트 상태 불일치 감지 → 이벤트 생성 + + + + + + +동시 처리 + +WebSocket 알림실시간 브로드캐스트 + +TimescaleDB 저장시계열 이벤트 기록 + +S3 영상 업로드Celery 비동기 처리 + + + + +관리자 대시보드실시간 알림 수신 및 현황 확인 + + +처리완료 / 오탐신고상태 업데이트 + +전체 시스템 흐름 + +
+ + +
02. 로그인 → 대시보드 흐름
+
+ + + + + + +관리자 로그인 시도이메일 + 비밀번호 입력 + +POST /api/auth/login이메일 + 비밀번호 검증 + + + + +성공 +실패 +✓ 인증 성공JWT access_token 발급 +✕ 인증 실패401 오류 반환 + + +성공 시 +대시보드 진입토큰 헤더 포함 API 호출 + +대시보드 데이터 로드이벤트 목록 조회 + WebSocket 연결 +로그인 → 대시보드 흐름 + +
+ + +
03. AI 감지 → 알림 → 관리자 처리 흐름
+
+ + + + + + +AI 무임승차 감지YOLOv11 + ByteTrack 탐지 완료 + +이벤트 생성DB 저장 + WebSocket 브로드캐스트 + S3 업로드POST /api/events/ + +관리자 대시보드 알림 수신실시간 팝업 알림 + +상세보기 확인영상 클립 + 감지 상세 정보 조회GET /api/events/{id} + + + +판정 + + +처리완료 +처리완료 처리PATCH /status + + +오탐 +오탐 신고 접수오탐신고창으로 이동 +AI 감지 → 알림 → 관리자 처리 흐름 + +
+ + +
04. 오탐신고 흐름
+
+ + + + + + +관리자 알림 확인대시보드에서 오탐 의심 이벤트 발견 + +상세보기 확인영상 클립 + 감지 정보 재확인 + +오탐신고창 열기사건사진 + 오탐 사유 선택 + +오탐 사유 선택 +정상태그 인식 지연 / 시스템 오류 +기계 결함 / 기타 직접 작성 + +오탐신고 완료이벤트 상태 변경POST /api/events/{id}/false-alarm +오탐신고 흐름 + +
+ +
+ + + + +
+
PART 03
+

API 연동 가이드

+

실제 구현된 백엔드 API 스펙 및 연동 방법

+
+ +
인증이 필요한 API는 요청 헤더에 반드시 포함: Authorization: Bearer {access_token}
+ +
01. 인증 (Authentication)
+ + + +
메서드엔드포인트인증 필요설명
POST/api/auth/login불필요관리자 로그인 후 JWT 토큰 발급
+ + + + + + +
요청 필드타입필수설명
emailstring관리자 이메일
passwordstring관리자 비밀번호
+ + + + + + +
응답 필드타입설명
access_tokenstringJWT 액세스 토큰
token_typestring고정값: bearer
+ +
02. 카메라 (Cameras)
+ + + + + + + +
메서드엔드포인트인증 필요설명
GET/api/cameras/등록된 카메라 전체 목록 조회
POST/api/cameras/새 카메라 등록
PATCH/api/cameras/{camera_id}/toggle카메라 활성/비활성 토글
+ +
03. 이벤트 (Events)
+ + + + + + + +
메서드엔드포인트인증 필요설명
GET/api/events/무임승차 이벤트 목록 조회 (최신순)
POST/api/events/불필요AI 추론 결과로 이벤트 생성 (AI 전용)
PATCH/api/events/{event_id}/status이벤트 상태 수동 업데이트
+ + + + + + + + + + + +
응답 필드타입설명
idinteger이벤트 고유 ID
camera_idinteger감지된 카메라 ID
timestampdatetime이벤트 발생 시각 (ISO 8601)
clip_urlstring / nullS3에 저장된 영상 클립 URL
track_idinteger / nullByteTrack 객체 추적 ID
confidencefloat / nullAI 감지 신뢰도 (0.0 ~ 1.0)
statusstring이벤트 상태값
+ +
04. 알림 (Notifications)
+ + + + + + +
메서드엔드포인트인증 필요설명
GET/api/notifications/불필요알림 목록 조회 (?unread_only=true로 미읽음만)
PATCH/api/notifications/{id}/read불필요알림 읽음 처리
+ +
05. 실시간 알림 (WebSocket)
+
엔드포인트: ws://15.135.92.86:8000/ws/events
무임승차 감지 시 서버가 연결된 모든 클라이언트에 즉시 브로드캐스트합니다.
+ + + + + + + + + + + +
수신 필드타입설명
typestring메시지 타입 — NEW_EVENT
data.idinteger이벤트 ID
data.camera_idinteger감지된 카메라 ID
data.timestampdatetime이벤트 발생 시각
data.clip_urlstring / null영상 클립 S3 URL
data.statusstring이벤트 초기 상태
data.confidencefloat / nullAI 감지 신뢰도
+
+const ws = new WebSocket('ws://15.135.92.86:8000/ws/events');
+ws.onmessage = (event) => {
+  const msg = JSON.parse(event.data);
+  if (msg.type === 'NEW_EVENT') {
+    addAlertToList(msg.data); // 대시보드 리스트 업데이트
+  }
+}; +
+ +
06. M2 연동 우선순위
+ + + + + + + + + + + + +
우선순위기능관련 API비고
1순위로그인 / JWT 인증POST /api/auth/login모든 API 연동의 전제조건
1순위실시간 알림 수신WebSocket /ws/events대시보드 핵심 기능
1순위알림 리스트 조회GET /api/events/대시보드 리스트
2순위이벤트 상세보기GET /api/events/{id}수근님 구현 요청 필요
2순위처리완료 처리PATCH /api/events/{id}/status상세보기 버튼
2순위오탐신고POST /api/events/{id}/false-alarm수근님 구현 요청 필요
3순위대시보드 통계GET /api/events/stats수근님 구현 요청 필요
3순위카메라 목록GET /api/cameras/설정 화면
+ +
+ + +
+ +
+
PART 04
+

인프라 현황

+

담당: 이동근  |  M2 작업 기준 (2026.04.01)

+
+ +
01. 서버 환경
+ + + + + + + + + +
항목상세상태
클라우드 서버AWS EC2 (Ubuntu 24.04 LTS, t3.micro, 시드니 ap-southeast-2)운영 중
퍼블릭 IP15.135.92.86정상
도메인https://gateguardsystems.com연결 완료
API 문서http://15.135.92.86:8000/docs정상 접속
Nginx + SSLCertbot SSL 발급 완료, 리버스 프록시 설정 중 (태양님)진행 중
+ +
02. Docker 컨테이너 현황
+ + + + + + + + +
컨테이너이미지역할상태
gateguard-backend-1gateguard-backend:latestFastAPI API 서버실행 중
gateguard-worker-1gateguard-worker:latestCelery 비동기 워커실행 중
gateguard-db-1timescale/timescaledb:latest-pg16TimescaleDB실행 중
gateguard-redis-1redis:7-alpineCelery 브로커 / 캐시실행 중
+ +
03. 보안 그룹 (인바운드 규칙)
+ + + + + + + + +
포트프로토콜용도소스
22TCPSSH 접속0.0.0.0/0
80TCPHTTP0.0.0.0/0
443TCPHTTPS0.0.0.0/0
8000TCPFastAPI 직접 접속0.0.0.0/0
+ +
04. M2 인프라 작업 현황
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
작업담당상세상태
Docker Log Rotation이동근로그 10MB 기준 자동 순환 (최대 3개 유지)
/etc/docker/daemon.json 설정 완료
완료
Docker 리소스 자동 정리이동근매일 새벽 3시 docker system prune -f 크론잡
로그: /var/log/docker-prune.log
완료
PostgreSQL S3 자동 백업이동근매일 새벽 2시 DB 덤프 → S3 업로드
버킷: gateguard-db-backup-909133988515-ap-northeast-2-an
스크립트: /usr/local/bin/db-backup.sh
완료
IAM 유저 생성 (S3 백업용)이동근유저: gateguard-s3-backup
정책: S3 PutObject / GetObject / ListBucket 최소 권한
완료
Nginx + SSL + 도메인최태양도메인: https://gateguardsystems.com
Certbot SSL 인증서 발급 완료, 리버스 프록시 설정 진행 중
진행 중
서버 리소스 모니터링최태양CPU/Memory 임계치 초과 시 팀장 알림 발송미완료
+ +
05. 크론탭 현황 (EC2 서버)
+
+ # 매일 새벽 2시 — DB 백업 후 S3 업로드
+ 0 2 * * *  /usr/local/bin/db-backup.sh >> /var/log/db-backup.log 2>&1

+ # 매일 새벽 3시 — Docker 불필요 리소스 자동 정리
+ 0 3 * * *  docker system prune -f >> /var/log/docker-prune.log 2>&1 +
+ +
+ 💾 디스크 사용률: 88.1% (6.71GB 중 약 5.9GB) — Log Rotation 및 Docker 자동 정리로 점진적 개선 예정
+ 🔐 보안: IAM 유저 기반 최소 권한 원칙 적용 (루트 액세스 키 미사용) +
+ + + + + + + diff --git a/docs/technical/OPTIMIZATION_REPORT.md b/docs/technical/OPTIMIZATION_REPORT.md new file mode 100644 index 0000000..51bc22d --- /dev/null +++ b/docs/technical/OPTIMIZATION_REPORT.md @@ -0,0 +1,46 @@ +# 🛰️ GateGuard — Intermediate Optimization & Cleanup (Post-M1) + +> **전술적 지침**: Milestone 1.0 (Foundation) 완료 후, Milestone 2.0 (High-Intensity Blitz) 돌입 전 시스템의 안정성, 가독성, 확장성을 극대화하기 위한 "무중단 중간 정비" 보고서입니다. (2026-03-31) + +--- + +## 🛠️ Optimization Summary (정비 요약) + +| 구역 | 작업 계획 | 기대 효과 | 팀원 영향도 | +| :--- | :--- | :--- | :--- | +| **Backend** | API 응집 통합 & Type Hinting 보강 | 코드 안정성 향상 및 자동 문서화(Swagger) 고도화 | **Zero (Compatibility 유지)** | +| **Database** | TimescaleDB 하이퍼테이블 인덱스 최종 검수 | 무임승차 대량 조회 시 성능 200% 향상 | **Zero (Schema 보존)** | +| **Documentation** | 파편화된 기술 문서 통합 (`docs/` 구조화) | 새로운 기능 추가 시 문서 탐색 비용 0.1초 미만 | **High (정보 접근 용이)** | +| **Dependency** | `requirements.txt` 불필요 패키지 정리 | Docker 빌드 시간 단축 및 보안 취약점 차단 | **Low (환경 재동기화 필요)** | + +--- + +## 🚀 영역별 세부 작업 내역 (Action Items) + +### 1. Backend: 정밀 튜닝 (Refinement) + +- [x] **Global Exception Handler 보강**: 모든 500 에러를 `{ "success": false, "message": "...", "detail": "..." }` 포맷으로 통일. 🛡️ +- [x] **Type Hints & Docstrings 이식**: `main.py`, `models.py`, `schemas.py`의 모든 함수에 사양 정보 명시. +- [x] **Import Cleanup**: 쓰이지 않는 더미 코드 및 라이브러리 소멸. + +### 2. Database: 시계열 성능 사수 (Performance) + +- [x] **Index 정밀 분석**: `events` 테이블에 `camera_id`와 `timestamp` 복합 인덱스 누락 여부 최종 확인. 🛢️ +- [ ] **[M2 이관] Hypertable Retention Policy**: 데이터 실사용량 분석 및 팀장 최종 승인 후 설정 (안정성 보장). + +### 3. Documentation: 지식 보관소 통합 (Docs Structure) + +- [x] **`docs/technical/`** 하위에 모든 `...GUIDE.md` 통합 관리. +- [x] **`README.md`** 최신화: M1 통합 완료 및 M2 목표 전면 노출. 📑 + +### 4. Dependency: 보급망 정예화 (Environment) + +- [ ] **[M2 이관] requirements.txt cleanup**: AI/Infra 팀원의 라이브러리 사용성 최종 확정 후 일괄 정제 (작업 연속성 수호). + +### 5. Environment & Compatibility: 범용 호환성 사수 (Stability) +- [x] **Python 3.9~3.12 전방위 지원**: `|` Union 문법을 `Optional`로 전용하여 로컬/서버 환경 불일치 리스크 제거. 🛡️ +- [x] **구동 검증(Ignition Check) 완수**: `/health` (200 OK) 및 Swagger UI 레이아웃 정상 가동 1초 만에 확인 완료. ✅ + +--- + +## 🏁 최종 점검 일시: 2026-03-31 10:00 (By Soo-geun's Tactical AI) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..33cfdaa --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +GateGuard is a subway fare evasion real-time detection system. This is the **frontend** (React + TypeScript + Vite) for an admin dashboard that receives WebSocket alerts from a FastAPI backend running at `http://localhost:8000`. + +## Commands + +```bash +npm run dev # Start dev server (Vite HMR) +npm run build # Type-check + production build (tsc -b && vite build) +npm run lint # ESLint +npm run preview # Preview production build +``` + +No test runner is configured yet. + +## Architecture + +### Auth Flow +- Login via `POST /api/auth/login` → receives `access_token` (JWT) +- Token stored in `localStorage` (remember me) or `sessionStorage` (session only) +- All API calls use the singleton `src/api/axios.ts` instance, which auto-injects the Bearer token via request interceptor and redirects to `/` on 401 +- **Route guard is NOT yet implemented** — all routes are accessible without a token + +### Routing (`src/router/index.tsx`) +- `/` → `LoginPage` (public) +- `/dashboard` → `DashboardPage` ✅ 구현완료 +- `/stats` → `StatsPage` ⚠️ placeholder ("준비 중" 텍스트만) +- `/events` → `EventsPage` ✅ 구현완료 +- `/settings` → `SettingsPage` ⚠️ placeholder ("준비 중" 텍스트만) + +### Layout Pattern +Dashboard pages share a consistent layout: `` (left, fixed w-64) + `
` (top) + `
` content. Assemble these manually in each page — no shared layout wrapper component. + +### Data Flow (DashboardPage) +`DashboardPage` is the single source of truth for all dashboard data: +- Owns 5 parallel API fetches on mount via `Promise.allSettled` (cameras, events, stats, cameraStats, notifications) +- `cameraMapRef`로 카메라 맵 관리 — WebSocket 핸들러에서도 최신 맵 참조 가능 +- Owns modal state (`selectedEvent`, `falseAlarmEvent`) +- Passes data down as props; children call `refresh()` after mutations +- WebSocket `NEW_EVENT` → 카메라 정보 조인 후 prepend to `events[]` (최대 10건) + optimistic stats increment + +### Component Organization +- `src/components/layout/` — `Sidebar`, `Header` +- `src/components/dashboard/` — `StatCards`, `StatCard`, `AlertList`, `AlertItem`, `CameraStats`, `FalseAlarmList`, `EventDetailModal`, `FalseAlarmModal` +- `src/components/events/` — `EventsFilter`, `EventsTable`, `EventsPagination` +- `src/components/ui/` — shadcn/ui primitives (generated via `npx shadcn add `) +- `src/contexts/AppContext.tsx` — 전역 상태 (`wsConnected`, `unconfirmedCount`) — Header·Sidebar에서 읽고 DashboardPage·EventsPage에서 설정 +- `src/hooks/` — `useWebSocket` (auto-reconnect, 3s delay, `connected` 반환, per-effect `let active` 패턴) +- `src/api/` — `axios.ts` (singleton), `events.ts`, `cameras.ts`, `notifications.ts` +- `src/types/index.ts` — 앱 전체 공유 타입 (`EventResponse`, `EventStats`, `CameraEventStats`, `NotificationResponse`) + +### Styling +- Tailwind CSS v4 (via `@tailwindcss/vite` plugin, no `tailwind.config.js`) +- Brand primary: `#4B73F7` +- shadcn/ui with `radix-nova` style, CSS variables enabled, `lucide-react` icons + +### Path Alias +`@/` → `src/` (configured in `vite.config.ts` and `tsconfig.app.json`) + +## Backend Integration + +- REST API base: `VITE_API_BASE_URL` 환경변수 (`src/api/axios.ts`) — always import from `@/api/axios`, never use raw `axios` +- WebSocket: `VITE_WS_URL` 환경변수 (`src/hooks/useWebSocket.ts`) +- 기본값은 `.env` 파일에 정의 (`http://localhost:8000`, `ws://localhost:8000/ws/events`) +- Run the full stack with `docker-compose up -d` from the repo root (`/Users/ijihyeon/Desktop/GateGuard/`) + +### 구현된 API (Swagger 확인 완료) +- `GET /api/cameras/` — 카메라 목록 +- `GET /api/events/` — 이벤트 목록 +- `PATCH /api/events/{event_id}/status` — 이벤트 상태 변경 (처리완료) ✅ +- `GET /api/notifications/` — 알림 목록 (인증 불필요) +- `PATCH /api/notifications/{notification_id}/read` — 알림 읽음 처리 + +### Backend M2 미구현 API (프론트 코드는 작성 완료, 호출 시 404 실패) +이 API들은 실패해도 각 컴포넌트가 null/빈 배열로 graceful fallback 처리: +- `GET /api/events/stats` → StatCards 데이터 (실패 시 카드 0으로 표시) +- `GET /api/events/stats/by-camera` → CameraStats 테이블 (실패 시 빈 테이블) +- `POST /api/events/{id}/false-alarm` → 오탐신고 (실패 시 신고 반영 안 됨) + +## 구현 현황 + +### ✅ 완료 +- 로그인 페이지 (JWT 인증, remember me) +- axios 공통 인스턴스 (토큰 자동 주입, 401 리다이렉트) +- WebSocket 훅 (`useWebSocket`) — 자동 재연결 +- 공통 TypeScript 타입 (`src/types/index.ts`) +- API 모듈 (`events.ts`, `notifications.ts`) +- Sidebar + Header 레이아웃 +- DashboardPage 전체 (API 연동, WebSocket, 4개 위젯, 2개 모달) + - StatCards — 오늘 감지, 미확인, 처리완료, 오탐 4개 카드 + - AlertList / AlertItem — 최신 알림 10건, 상세보기/오탐신고 버튼 + - CameraStats — 구간별 알림현황 테이블 (고위험/주의/정상 색상 분기) + - FalseAlarmList — 최근 오탐 신고 5건 + - EventDetailModal — 좌측 실시간 알림 목록 + 우측 상세 정보 + 역무원파견/처리완료/오탐신고 버튼 + - FalseAlarmModal — 오탐 사유 선택 + 직접입력 +- EventsPage (전체 발생내역 — 필터/페이지네이션, WebSocket 실시간 삽입, EventDetailModal·FalseAlarmModal 재사용) +- SettingsPage placeholder (`/settings` 라우트 등록) +- AppContext (`wsConnected`, `unconfirmedCount` 전역 공유) +- Sidebar 미확인 뱃지 (unconfirmedCount > 0 시 빨간 동그라미) +- Header WS 뱃지 (wsConnected 기반 on/off) +- 환경변수 분리 (`VITE_API_BASE_URL`, `VITE_WS_URL`) + +### ⚠️ 미구현 (우선순위 순) +1. **Auth route guard** — 토큰 없으면 `/`로 리다이렉트 (현재 모든 라우트 인증 없이 접근 가능) +2. **StatsPage** — ECharts 통계 시각화 (현재 placeholder) +3. **SettingsPage** — 설정 기능 (현재 placeholder) + +### 로그인 현황 및 블로커 +현재 로그인이 불가능하며 두 가지 문제가 모두 해결되어야 함: +1. **CORS 미해결** — 브라우저가 `POST /api/auth/login` 전 OPTIONS 프리플라이트 요청을 보내는데 백엔드가 `http://localhost:5173`을 허용하지 않아 400 반환. 실서버 연결 시 백엔드 `main.py`에 FastAPI `CORSMiddleware` 추가 필요 (`allow_origins=["http://localhost:5173"]`) +2. **DB 미연결** — CORS 해결 후에도 DB가 연결되지 않으면 로그인 쿼리가 hang → `await api.post('/api/auth/login')` 무한 대기 → "로그인 중..." 무한 로딩 +- 로그인 없이 `/dashboard` 직접 접근 시 토큰 없음 → 보호된 API 401 반환 (정상 동작, Auth route guard로 해결 예정) + +### EventStatus 값 +백엔드 확정 상태값 3종: `pending` (미처리) | `confirmed` (처리완료) | `false_alarm` (오탐) +- `pending` → 상세보기·오탐신고 버튼 활성화, 빨간 dot 표시 +- `confirmed` / `false_alarm` → 기록보기 버튼만 표시 + +### EventStats API 응답 필드명 +`GET /api/events/stats` 응답: `today_total`, `pending`, `confirmed`, `false_alarm` +(기존 `today_count`, `pending_count` 등과 다름 — 이미 `src/types/index.ts`에 반영 완료) + +### 백엔드 확정 후 수정 필요 +- `appearance_tags`, `description`, `event_type`, `assigned_to` 필드 실제 응답 포함 여부 +- `POST /api/events/{id}/false-alarm` 요청 바디 필드명 (`reason`, `memo?`) 확정 필요 +- `NotificationResponse`에 `event` 필드 embed 여부 확인 필요 +- clip_url S3 CORS 설정 (백엔드 담당) +- CameraStats 색상 임계값(현재 5/2) 기획 확정 필요 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 485f915..837c89e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,13 @@ import { RouterProvider } from 'react-router-dom' import { router } from './router' +import { AppProvider } from './contexts/AppContext' function App() { - return + return ( + + + + ) } -export default App \ No newline at end of file +export default App diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 9e2ee8f..5a89d12 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -1,29 +1,21 @@ /** * @file axios.ts - * @description axios 인스턴스 및 인터셉터 설정 + * @description axios 공통 인스턴스 * - * ## 주요 기능 - * - baseURL: http://localhost:8000 - * - Request interceptor: localStorage 또는 sessionStorage에서 토큰을 꺼내 - * 모든 요청 헤더에 `Authorization: Bearer ` 자동 주입 - * (로그인 시 "비밀번호 기억하기" 체크 여부에 따라 저장 위치가 다름) - * - Response interceptor: 401 응답 시 토큰 삭제 후 로그인 페이지(/)로 리다이렉트 - * - * ## 사용법 - * ```ts - * import api from '@/api/axios' - * const res = await api.get('/api/cameras') - * ``` + * ## 기능 + * - 모든 요청에 Bearer 토큰 자동 주입 (localStorage → sessionStorage 순으로 탐색) + * - 401 응답 시 토큰 삭제 후 로그인(/)으로 리다이렉트 * * ## 주의사항 - * - 모든 API 호출은 axios 기본 인스턴스 대신 이 파일의 api 인스턴스를 사용할 것 + * - 모든 API 호출은 기본 axios 대신 이 인스턴스(api) 사용할 것 * - 토큰 저장/삭제는 이 파일과 LoginPage.tsx에서만 처리할 것 + * - baseURL은 VITE_API_BASE_URL 환경변수로 관리 (.env 파일 참고) */ import axios from "axios"; const api = axios.create({ - baseURL: "http://localhost:8000", + baseURL: import.meta.env.VITE_API_BASE_URL as string, headers: { "Content-Type": "application/json", }, diff --git a/frontend/src/api/cameras.ts b/frontend/src/api/cameras.ts new file mode 100644 index 0000000..942f312 --- /dev/null +++ b/frontend/src/api/cameras.ts @@ -0,0 +1,17 @@ +/** + * @file api/cameras.ts + * @description 카메라 목록 조회 API + * + * ## 기능 + * - getCameras() GET /api/cameras/ 전체 카메라 목록 (station_name, location 포함) + * + * ## 주의사항 + * - 이벤트 API 응답에는 camera_id만 있고 위치 정보가 없으므로, + * 이벤트 표시 시 이 API로 가져온 카메라 맵과 조인하여 역이름/게이트 표시 + */ + +import api from "./axios"; +import type { CameraResponse } from "@/types"; + +export const getCameras = () => + api.get("/api/cameras/").then((r) => r.data); diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts new file mode 100644 index 0000000..5ea7297 --- /dev/null +++ b/frontend/src/api/events.ts @@ -0,0 +1,46 @@ +/** + * @file api/events.ts + * @description 이벤트(무임승차 감지) 관련 API + * + * ## 기능 + * - getEvents(params?) GET /api/events/ 이벤트 목록 조회 + * - getEventById(id) GET /api/events/{id} 이벤트 단건 조회 + * - getEventStats() GET /api/events/stats 통계 카드 데이터 + * - getEventStatsByCamera() GET /api/events/stats/by-camera 구간별 알림현황 + * - updateEventStatus(id) PATCH /api/events/{id}/status 처리완료 상태 변경 + * - reportFalseAlarm(id) POST /api/events/{id}/false-alarm 오탐신고 + * + * ## 주의사항 + * - getEventStats / getEventStatsByCamera / reportFalseAlarm / updateEventStatus: 백엔드 M2 구현 예정, 실패 시 호출 측에서 fallback 처리 + * + * ## 백엔드 확정 후 수정 필요 + * - reportFalseAlarm 요청 바디 필드명 ({ reason, memo? } 로 임시 처리) + */ + +import api from "./axios"; +import type { EventResponse, EventStats, CameraEventStats } from "@/types"; + +export const getEvents = (params?: { + limit?: number; + status?: string; + camera_id?: number; +}) => api.get("/api/events/", { params }).then((r) => r.data); + +export const getEventById = (id: number) => + api.get(`/api/events/${id}`).then((r) => r.data); + +export const getEventStats = () => + api.get("/api/events/stats").then((r) => r.data); + +export const getEventStatsByCamera = () => + api + .get("/api/events/stats/by-camera") + .then((r) => r.data); + +export const updateEventStatus = (id: number, status: string) => + api.patch(`/api/events/${id}/status`, { status }).then((r) => r.data); + +export const reportFalseAlarm = ( + id: number, + body: { reason: string; memo?: string }, +) => api.post(`/api/events/${id}/false-alarm`, body).then((r) => r.data); diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 0000000..410a0f7 --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,26 @@ +/** + * @file api/notifications.ts + * @description 알림(오탐 신고 내역) 관련 API + * + * ## 기능 + * - getNotifications(params?) GET /api/notifications/ 알림 목록 조회 + * - markNotificationRead(id) PATCH /api/notifications/{id}/read 읽음 처리 + * + * ## 주의사항 + * - GET /api/notifications/ 는 인증 불필요 API + * + * ## 백엔드 확정 후 수정 필요 + * - NotificationResponse에 event 정보 embed 여부 확인 필요 + * - markNotificationRead 호출 시점 확인 필요 (항목 클릭 시? 자동?) + */ + +import api from "./axios"; +import type { NotificationResponse } from "@/types"; + +export const getNotifications = (params?: { unread_only?: boolean }) => + api + .get("/api/notifications/", { params }) + .then((r) => r.data); + +export const markNotificationRead = (id: number) => + api.patch(`/api/notifications/${id}/read`).then((r) => r.data); diff --git a/frontend/src/components/dashboard/AlertItem.tsx b/frontend/src/components/dashboard/AlertItem.tsx new file mode 100644 index 0000000..798c89f --- /dev/null +++ b/frontend/src/components/dashboard/AlertItem.tsx @@ -0,0 +1,145 @@ +/** + * @file components/dashboard/AlertItem.tsx + * @description 최신알림 개별 카드 컴포넌트 + * + * ## 기능 + * - 미처리(pending/detected): 파란 [상세보기] + 파란 outline [오탐신고] 버튼 + * - 처리완료(confirmed/false_alarm): 파란 outline [기록보기] 버튼 + * - 심각도 뱃지: confidence 기반 (≥0.7 고위험 / <0.7 중간 / 처리 후 처리완료·오탐) + * - 위치: camera embed 시 "역명 게이트명", 루트 직접 필드 시에도 동일 표시, 없으면 "CAM-XX" + * - description 있으면 감지 유형 표시, 없으면 status 기반 기본 문구 fallback + * + * ## 주의사항 + * - isActive: pending → 상세보기·오탐신고 버튼 / confirmed | false_alarm → 기록보기 버튼 + * - getLocationLabel: camera 객체 embed 또는 루트 직접 필드(station_name/location) 모두 대응, 없으면 "CAM-XX" fallback + * + * ## TODO + * - [ ] 카메라 썸네일 실제 CCTV 스냅샷 연동 (clip_url or 별도 API) + * + * ## 백엔드 확정 후 수정 필요 + * - description, appearance_tags 필드명 확정 필요 + */ + +import type { EventResponse } from "@/types"; + +interface AlertItemProps { + event: EventResponse; + onDetail: (event: EventResponse) => void; + onFalseAlarm: (event: EventResponse) => void; +} + +/** confidence 기반 심각도 뱃지. 처리 완료 상태는 별도 레이블 반환 */ +function getSeverity(event: EventResponse): { label: string; color: string } { + if (event.status === "confirmed") + return { label: "처리완료", color: "bg-gray-100 text-gray-500" }; + if (event.status === "false_alarm") + return { label: "오탐", color: "bg-gray-100 text-gray-400" }; + if ((event.confidence ?? 0) >= 0.7) + return { label: "고위험", color: "bg-red-100 text-red-500" }; + return { label: "중간", color: "bg-yellow-100 text-yellow-600" }; +} + +function formatTime(timestamp: string): string { + const d = new Date(timestamp); + return `${d.getHours()}시 ${String(d.getMinutes()).padStart(2, "0")}분`; +} + +/** + * 위치 레이블: DashboardPage에서 카메라 API 조인 후 event.camera가 주입됨 + * 조인 실패 시 "CAM-XX" fallback + */ +function getLocationLabel(event: EventResponse): string { + const station = event.camera?.station_name; + const gate = event.camera?.location; + if (station && gate) return `${station} ${gate}`; + if (station) return station; + return `CAM-${String(event.camera_id).padStart(2, "0")}`; +} + +/** description 있으면 그 값, 없으면 status 기반 기본 문구 */ +function getDescription(event: EventResponse): string { + if (event.description) return event.description; + if (event.status === "confirmed") return "무임승차 확인 처리됨"; + if (event.status === "false_alarm") return "오탐으로 처리됨"; + return "무임승차 의심 감지"; +} + +export default function AlertItem({ + event, + onDetail, + onFalseAlarm, +}: AlertItemProps) { + const severity = getSeverity(event); + const isActive = event.status === "pending"; + + return ( +
+ {/* 카메라 썸네일 */} +
+
+ CAM-{String(event.camera_id).padStart(2, "0")} +
+ + {severity.label} + +
+ + {/* 내용 */} +
+
+ + {getLocationLabel(event)} + + + {formatTime(event.timestamp)} + +
+

{getDescription(event)}

+
+ {event.appearance_tags?.map((tag) => ( + + {tag} + + ))} + {event.confidence !== null && ( + + 신뢰도 {Math.round((event.confidence ?? 0) * 100)}% + + )} +
+
+ + {/* 버튼 */} +
+ {isActive ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/AlertList.tsx b/frontend/src/components/dashboard/AlertList.tsx new file mode 100644 index 0000000..89b0153 --- /dev/null +++ b/frontend/src/components/dashboard/AlertList.tsx @@ -0,0 +1,76 @@ +/** + * @file components/dashboard/AlertList.tsx + * @description 대시보드 최신알림 목록 컴포넌트 + * + * ## 기능 + * - events 배열 렌더링 (데이터 페칭은 DashboardPage에서 담당) + * - loading 시 스켈레톤 3개 / 빈 배열 시 안내 문구 표시 + * + * ## 주의사항 + * - 전체보기 버튼은 Link to="/events"로 연결됨 + * - 최대 10건 표시 (DashboardPage에서 limit=10 페칭 + WebSocket .slice(0, 10) 유지) + */ + +import { Link } from "react-router-dom"; +import AlertItem from "./AlertItem"; +import type { EventResponse } from "@/types"; + +interface AlertListProps { + events: EventResponse[]; + loading?: boolean; + unconfirmedCount: number; + onDetail: (event: EventResponse) => void; + onFalseAlarm: (event: EventResponse) => void; +} + +export default function AlertList({ + events, + loading, + unconfirmedCount, + onDetail, + onFalseAlarm, +}: AlertListProps) { + return ( +
+
+
+

최신알림

+ {unconfirmedCount > 0 && ( + + 미확인 {unconfirmedCount}건 + + )} +
+ + 전체보기 + +
+ + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : events.length === 0 ? ( +

+ 감지된 이벤트가 없습니다. +

+ ) : ( +
+ {events.map((event) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/CameraStats.tsx b/frontend/src/components/dashboard/CameraStats.tsx new file mode 100644 index 0000000..01e0bb3 --- /dev/null +++ b/frontend/src/components/dashboard/CameraStats.tsx @@ -0,0 +1,97 @@ +/** + * @file components/dashboard/CameraStats.tsx + * @description 구간별 알림현황 테이블 컴포넌트 + * + * ## 기능 + * - CameraEventStats[] 를 테이블로 렌더링 + * - count ≥ 5 → 고위험(빨강) / ≥ 2 → 주의(노랑) / < 2 → 정상(회색) + * - loading 시 스켈레톤 / 빈 배열 시 안내 문구 표시 + * + * ## 주의사항 + * - GET /api/events/stats/by-camera 는 백엔드 M2 구현 예정, 전까지 빈 테이블 표시 + * + * ## TODO + * - [ ] 지도보기 버튼 기능 구현 + * - [ ] 행 클릭 시 해당 카메라 이벤트 필터링 이동 + * + * ## 협의 + * - 색상 분기 임계값(5, 2) 기획 확정 후 수정 필요 + */ + +import { MapPin } from "lucide-react"; +import type { CameraEventStats } from "@/types"; + +interface CameraStatsProps { + data: CameraEventStats[]; + loading?: boolean; +} + +function getDotColor(count: number): string { + if (count >= 5) return "bg-red-500"; + if (count >= 2) return "bg-yellow-400"; + return "bg-gray-300"; +} + +function getCountColor(count: number): string { + if (count >= 5) return "text-red-500"; + if (count >= 2) return "text-yellow-500"; + return "text-gray-500"; +} + +export default function CameraStats({ data, loading }: CameraStatsProps) { + return ( +
+
+

구간별 알림현황

+ +
+ + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : data.length === 0 ? ( +

+ 데이터가 없습니다. +

+ ) : ( + + + + + + + + + {data.map((row) => ( + + + + + ))} + +
역이름알림현황
+
{row.station_name}
+
{row.location}
+
+ + + {row.count} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/EventDetailModal.tsx b/frontend/src/components/dashboard/EventDetailModal.tsx new file mode 100644 index 0000000..0ab18aa --- /dev/null +++ b/frontend/src/components/dashboard/EventDetailModal.tsx @@ -0,0 +1,350 @@ +/** + * @file components/dashboard/EventDetailModal.tsx + * @description 알림 상세보기 모달 + * + * ## 기능 + * - 좌측: events 목록, 클릭 시 우측 상세 전환 + * - 우측: clip_url 영상 + 기록시각 / 위치 / 인상착의 / AI 신뢰도 + * - 처리완료: PATCH /api/events/{id}/status { status: 'confirmed' } 후 닫기 + * - 오탐신고: FalseAlarmModal로 전환 + * - status === "pending" 일 때 역무원파견/처리완료/오탐신고 버튼 표시, 그 외 미표시 + * + * ## 주의사항 + * - event 데이터는 AlertList의 events 배열 그대로 사용 (별도 단건 조회 없음) + * + * ## TODO + * - [ ] 역무원 파견 API 연동 (백엔드 스펙 미정) + * - [ ] GET /api/events/{id} 백엔드 구현 후 상세 데이터 별도 조회로 전환 + * + * ## 협의 + * - clip_url S3 CORS 설정 백엔드(조수근) 확인 필요 + * - appearance_tags, description 필드명 백엔드 확정 후 수정 필요 + */ + +import { useState } from "react"; +import { X, Clock, MapPin, Tag, Video, Zap } from "lucide-react"; +import type { EventResponse } from "@/types"; +import { updateEventStatus } from "@/api/events"; + +interface EventDetailModalProps { + events: EventResponse[]; + initialEvent: EventResponse; + onClose: () => void; + onFalseAlarm: (event: EventResponse) => void; + onConfirmed: () => void; +} + +// ── 유틸 ────────────────────────────────────────────── + +function formatHMS(timestamp: string): string { + const d = new Date(timestamp); + return [d.getHours(), d.getMinutes(), d.getSeconds()] + .map((n) => String(n).padStart(2, "0")) + .join(":"); +} + +function getStatusText(status: EventResponse["status"]): string { + if (status === "confirmed") return "CONFIRMED"; + if (status === "false_alarm") return "FALSE ALARM"; + return "UNCONFIRMED"; +} + +function getGateLabel(event: EventResponse): string { + return event.camera?.location ?? `GATE ${event.camera_id}`; +} + +function getSeverityBadge(event: EventResponse): { + label: string; + cls: string; +} { + if (event.status === "confirmed") + return { label: "처리완료", cls: "bg-green-100 text-green-600" }; + if (event.status === "false_alarm") + return { label: "오탐", cls: "bg-gray-100 text-gray-500" }; + if ((event.confidence ?? 0) >= 0.7) + return { label: "고위험", cls: "bg-red-100 text-red-500" }; + return { label: "중간", cls: "bg-yellow-100 text-yellow-600" }; +} + +// ── 좌측 패널: 이벤트 목록 아이템 ────────────────────── + +function ListItem({ + event, + isSelected, + onClick, +}: { + event: EventResponse; + isSelected: boolean; + onClick: () => void; +}) { + const isUnconfirmed = event.status === "pending"; + + return ( + + ); +} + +// ── 메인 모달 ───────────────────────────────────────── + +export default function EventDetailModal({ + events, + initialEvent, + onClose, + onFalseAlarm, + onConfirmed, +}: EventDetailModalProps) { + const [selected, setSelected] = useState(initialEvent); + const [confirming, setConfirming] = useState(false); + + const isActive = selected.status === "pending"; + const badge = getSeverityBadge(selected); + const station = selected.camera?.station_name ?? ""; + const gate = selected.camera?.location ?? ""; + const locationText = + station && gate + ? `${station} ${gate}` + : station || `카메라 #${selected.camera_id}`; + + const handleConfirm = async () => { + setConfirming(true); + try { + await updateEventStatus(selected.id, "confirmed"); + onConfirmed(); + onClose(); + } catch { + alert("처리 중 오류가 발생했습니다."); + } finally { + setConfirming(false); + } + }; + + const handleDispatch = () => { + // TODO: 역무원 파견 API 연동 (백엔드 스펙 미정) + alert("역무원 파견 기능은 준비 중입니다."); + }; + + return ( +
+
e.stopPropagation()} + > + {/* ── 좌측: 실시간 알림 목록 (md 미만 숨김) ── */} +
+
+

실시간 알림

+
+
+ {events.length === 0 ? ( +

+ 이벤트 없음 +

+ ) : ( + events.map((ev) => ( + setSelected(ev)} + /> + )) + )} +
+
+ + {/* ── 우측: 상세 정보 ── */} +
+ {/* 헤더 */} +
+
+

+ Event #{selected.id} + + {" "} + | {formatHMS(selected.timestamp)} | {getGateLabel(selected)} | + STATUS:{" "} + + + {getStatusText(selected.status)} + +

+ + {badge.label} + +
+ +
+ + {/* 콘텐츠: 이미지 + 정보 */} +
+ {/* 이미지/영상 */} +
+ {selected.clip_url ? ( +
+ + {/* 정보 + 버튼 */} +
+ {/* 정보 섹션 */} +
+
+ +
+

기록시각

+

+ {formatHMS(selected.timestamp)} +

+
+
+ +
+ +
+

위치

+

{locationText}

+
+
+ + {selected.appearance_tags && + selected.appearance_tags.length > 0 && ( +
+ +
+

인상착의

+
    + {selected.appearance_tags.map((tag) => ( +
  • + + {tag} +
  • + ))} +
+
+
+ )} + + {(selected.event_type ?? selected.description) && ( +
+ +
+

감지유형

+

+ {selected.event_type ?? selected.description} +

+
+
+ )} + + {selected.confidence !== null && ( +

+ AI 신뢰도: {Math.round((selected.confidence ?? 0) * 100)}% +

+ )} +
+ + {/* 액션 버튼 */} + {isActive && ( +
+

+ 다음 버튼 +

+ + + +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/FalseAlarmList.tsx b/frontend/src/components/dashboard/FalseAlarmList.tsx new file mode 100644 index 0000000..9e678f4 --- /dev/null +++ b/frontend/src/components/dashboard/FalseAlarmList.tsx @@ -0,0 +1,105 @@ +/** + * @file components/dashboard/FalseAlarmList.tsx + * @description 대시보드 최근 오탐 신고 목록 컴포넌트 + * + * ## 기능 + * - GET /api/notifications/?unread_only=false 응답 최대 5건 표시 + * - read_at === null → 검토 중 (노란 아이콘) / read_at !== null → 오탐 확인 (초록 아이콘) + * - loading 시 스켈레톤 / 빈 배열 시 안내 문구 표시 + * + * ## 주의사항 + * - GET /api/notifications/ 는 인증 불필요 API + * + * ## TODO + * - [ ] 전체보기 버튼 → 오탐 신고 전체 목록 페이지 라우팅 연결 + * - [ ] 항목 클릭 시 해당 이벤트 상세보기 Modal 연동 + * + * ## 협의 + * - NotificationResponse에 event 정보 embed 여부 백엔드(조수근) 확인 필요 + */ + +import { Link } from "react-router-dom"; +import { AlertTriangle, CheckCircle } from "lucide-react"; +import type { NotificationResponse } from "@/types"; + +interface FalseAlarmListProps { + notifications: NotificationResponse[]; + loading?: boolean; +} + +function formatRelativeTime(timestamp: string): string { + const d = new Date(timestamp); + const now = new Date(); + const diffDays = Math.floor( + (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24), + ); + const timeStr = `${d.getHours()}시 ${String(d.getMinutes()).padStart(2, "0")}분`; + if (diffDays === 0) return `오늘 ${timeStr}`; + if (diffDays === 1) return `어제 ${timeStr}`; + return `${diffDays}일 전 ${timeStr}`; +} + +function getLabel(n: NotificationResponse): string { + if (n.event?.camera) { + return `${n.event.camera.station_name} CAM-${String(n.event.camera_id).padStart(2, "0")} - 오탐 신고`; + } + return `이벤트 #${n.event_id} - 오탐 신고`; +} + +export default function FalseAlarmList({ + notifications, + loading, +}: FalseAlarmListProps) { + return ( +
+
+

최근 오탐 신고

+ + 전체보기 + +
+ + {loading ? ( +
+ {[...Array(2)].map((_, i) => ( +
+ ))} +
+ ) : notifications.length === 0 ? ( +

+ 최근 오탐 신고 내역이 없습니다. +

+ ) : ( +
+ {notifications.slice(0, 5).map((n) => { + const isResolved = n.read_at !== null; + return ( +
+
+ {isResolved ? ( + + ) : ( + + )} +
+
+

+ {getLabel(n)} +

+

+ {formatRelativeTime(n.sent_at)} ·{" "} + {isResolved ? "오탐 확인" : "검토 중"} +

+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/FalseAlarmModal.tsx b/frontend/src/components/dashboard/FalseAlarmModal.tsx new file mode 100644 index 0000000..c6c2d85 --- /dev/null +++ b/frontend/src/components/dashboard/FalseAlarmModal.tsx @@ -0,0 +1,171 @@ +/** + * @file components/dashboard/FalseAlarmModal.tsx + * @description 오탐신고 모달 컴포넌트 + * + * ## 기능 + * - 오탐 사유 4가지 라디오 선택 (기타 선택 시 직접 입력) + * - POST /api/events/{id}/false-alarm { reason, memo? } 호출 후 닫기 + * - AlertItem 또는 EventDetailModal의 오탐신고 버튼으로 진입 + * + * ## 주의사항 + * - 오탐신고 완료 후 onSubmitted() 콜백으로 DashboardPage/EventsPage의 refresh() 호출 + * - POST /api/events/{id}/false-alarm 백엔드 M2 구현 예정 + * + * ## 백엔드 확정 후 수정 필요 + * - 요청 바디 필드명 ({ reason, memo? }) 확정 필요 + */ + +import { useState } from "react"; +import { X } from "lucide-react"; +import type { EventResponse } from "@/types"; +import { reportFalseAlarm } from "@/api/events"; + +interface FalseAlarmModalProps { + event: EventResponse; + onClose: () => void; + onSubmitted: () => void; +} + +const REASONS = [ + "정상태그이나 인식 지연", + "단순 시스템 오류", + "기계 결함", + "기타", +] as const; + +export default function FalseAlarmModal({ + event, + onClose, + onSubmitted, +}: FalseAlarmModalProps) { + const [selectedReason, setSelectedReason] = useState(""); + const [memo, setMemo] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const locationText = event.camera + ? `${event.camera.station_name} ${event.camera.location}` + : `카메라 #${event.camera_id}`; + + const handleSubmit = async () => { + if (!selectedReason) { + setError("오탐 사유를 선택해주세요."); + return; + } + if (selectedReason === "기타" && !memo.trim()) { + setError("사유를 직접 입력해주세요."); + return; + } + setError(""); + setLoading(true); + try { + const reason = selectedReason === "기타" ? memo.trim() : selectedReason; + await reportFalseAlarm(event.id, { + reason, + memo: selectedReason === "기타" ? memo.trim() : undefined, + }); + onSubmitted(); + onClose(); + } catch { + setError("오탐 신고 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+

오탐신고

+ +
+ + {/* 사건사진 (기능명세서: EventResponse.clip_url 활용) */} +
+ {event.clip_url ? ( +
+ +
+ {/* 이벤트 요약 */} +
+

{locationText}

+

이벤트 #{event.id}

+
+ + {/* 오탐 사유 선택 */} +
+

오탐 사유

+ {REASONS.map((reason) => ( + + ))} +
+ + {/* 기타 직접 입력 */} + {selectedReason === "기타" && ( +