3070 lines
183 KiB
Plaintext
3070 lines
183 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"# 🎯 [OPS01] 드론 영상 공중위협 객체 탐지와 오경보 검증\n",
|
|
"### Google Colab 실습 환경 (작전·전투 수행 / Level 3)\n",
|
|
"\n",
|
|
"[](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/OPS01/OPS01_solution.ipynb)\n",
|
|
"\n",
|
|
"---\n",
|
|
"\n",
|
|
"## 📌 1. 프로젝트 개요 및 배경\n",
|
|
"\n",
|
|
"| 항목 | 내용 |\n",
|
|
"| :--- | :--- |\n",
|
|
"| **과정 분류** | 작전·전투 수행 (Level 3) |\n",
|
|
"| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n",
|
|
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
|
|
"\n",
|
|
"### 📖 프로젝트 목표\n",
|
|
"러시아-우크라이나 전쟁에서는 소형 드론이 정찰과 공격에 대량으로 쓰이면서, 영상으로 공중위협을 빨리 찾아내는 일만큼 조류·민간 항공기·지상 반사체 때문에 생기는 오경보를 줄이는 일이 중요해졌다. 본 프로젝트에서는 교육용 가상 적외선 영상을 코드로 직접 만들어, 데이터 스키마 정의, 기상 왜곡 전처리, 탐지 Baseline, 군집·급강하 패턴 식별, 신뢰도 점수 산출, 전술 상황판, 인간 개입(HITL) 승인/보류, 포화 공격 스트레스 시험, AAR 재보정, 최종 통합까지 대드론 탐지·경보 파이프라인을 단계별로 구현한다. 최종 목표는 드론을 놓치지 않으면서 오경보를 줄이고, 모든 교전 관련 판단이 사람의 승인을 거치도록 설계하는 것이다.\n",
|
|
"\n",
|
|
"> **⚠️ [교육용 가상 데이터셋 원칙]**\n",
|
|
"> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n",
|
|
"> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n",
|
|
"\n",
|
|
"---\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# ============================================================================\n",
|
|
"# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n",
|
|
"# ============================================================================\n",
|
|
"!pip install -q numpy pandas matplotlib scipy scikit-learn\n",
|
|
"\n",
|
|
"import os\n",
|
|
"import sys\n",
|
|
"import time\n",
|
|
"import math\n",
|
|
"import json\n",
|
|
"import re\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"import warnings\n",
|
|
"warnings.filterwarnings('ignore')\n",
|
|
"\n",
|
|
"# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n",
|
|
"plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n",
|
|
"plt.rcParams['axes.unicode_minus'] = False\n",
|
|
"\n",
|
|
"print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 01. 전술 드론 영상 수집 환경 분석 및 공중위협 객체(드론/미사일) 데이터셋 스키마 정의\n",
|
|
"**소요시간**: 약 1.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"러시아-우크라이나 전쟁 전장 환경을 분석하여 드론 탑재 광학/적외선(EO/IR) 센서 규격, 공중위협 객체(정찰·자폭드론, 순항미사일) 메타데이터 및 바운딩 박스 라벨링 스키마를 설계하고 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론 탑재 EO/IR 영상으로 공중위협을 탐지할 때 필요한 센서 규격, 전술 파라미터, 바운딩 박스 라벨 구조를 정의하고 잘못된 데이터를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **정찰·자폭드론, 순항미사일, 조류, 민간기, 지상 반사체의 클래스 체계를 정하고 센서(EO/IR, 해상도, fps)와 전술 파라미터(기상, 위치오차, 통신지연) 필드를 포함한 스키마 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `data/schema/ops01_input_schema.json 및 docs/ops01_data_spec.md` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 작전·전투 수행 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"드론 탑재 EO/IR 영상의 공중위협 객체(정찰·자폭드론, 순항미사일) 메타데이터·라벨 스키마를 정의하고, 가상 샘플 데이터를 검증한다.\"\"\"\n",
|
|
"import json\n",
|
|
"import re\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"\n",
|
|
"# 1) 스키마 정의: 센서 규격, 전술 파라미터(기상·위치오차·통신지연), 바운딩 박스 라벨\n",
|
|
"SCHEMA = {\n",
|
|
" \"required\": [\"sequence_id\", \"sensor\", \"tactical\", \"n_frames\", \"labels\"],\n",
|
|
" \"fields\": {\n",
|
|
" \"sequence_id\": {\"type\": str, \"pattern\": r\"^SEQ\\d{3}$\"},\n",
|
|
" \"n_frames\": {\"type\": int, \"min\": 1, \"max\": 3000},\n",
|
|
" },\n",
|
|
" \"sensor\": {\n",
|
|
" \"type\": {\"type\": str, \"enum\": [\"EO\", \"IR\"]},\n",
|
|
" \"resolution\": {\"type\": list, \"length\": 2},\n",
|
|
" \"fps\": {\"type\": (int, float), \"min\": 1, \"max\": 60},\n",
|
|
" },\n",
|
|
" \"tactical\": {\n",
|
|
" \"weather\": {\"type\": str, \"enum\": [\"clear\", \"night\", \"fog\", \"rain\", \"smoke\"]},\n",
|
|
" \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n",
|
|
" \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n",
|
|
" },\n",
|
|
" \"label\": {\n",
|
|
" \"class\": {\"type\": str, \"enum\": [\"recon_drone\", \"attack_drone\", \"cruise_missile\", \"bird\", \"civil_aircraft\", \"clutter\"]},\n",
|
|
" \"bbox\": {\"type\": list, \"length\": 4},\n",
|
|
" \"frame\": {\"type\": int, \"min\": 0},\n",
|
|
" \"thermal\": {\"type\": (int, float), \"min\": 0, \"max\": 255},\n",
|
|
" },\n",
|
|
"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def check(name, value, rule):\n",
|
|
" \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n",
|
|
" if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n",
|
|
" return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n",
|
|
" errs = []\n",
|
|
" if \"enum\" in rule and value not in rule[\"enum\"]:\n",
|
|
" errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n",
|
|
" if \"min\" in rule and value < rule[\"min\"]:\n",
|
|
" errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n",
|
|
" if \"max\" in rule and value > rule[\"max\"]:\n",
|
|
" errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n",
|
|
" if \"length\" in rule and len(value) != rule[\"length\"]:\n",
|
|
" errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n",
|
|
" if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n",
|
|
" errs.append(f\"{name}: 형식 불일치 '{value}'\")\n",
|
|
" return errs\n",
|
|
"\n",
|
|
"\n",
|
|
"def validate(rec):\n",
|
|
" errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n",
|
|
" for k, rule in SCHEMA[\"fields\"].items():\n",
|
|
" if k in rec:\n",
|
|
" errs += check(k, rec[k], rule)\n",
|
|
" for group in (\"sensor\", \"tactical\"):\n",
|
|
" for k, rule in SCHEMA[group].items():\n",
|
|
" if k not in rec.get(group, {}):\n",
|
|
" errs.append(f\"{group}.{k}: 필수 필드 누락\")\n",
|
|
" else:\n",
|
|
" errs += check(f\"{group}.{k}\", rec[group][k], rule)\n",
|
|
" for i, lab in enumerate(rec.get(\"labels\", [])):\n",
|
|
" for k, rule in SCHEMA[\"label\"].items():\n",
|
|
" if k not in lab:\n",
|
|
" errs.append(f\"labels[{i}].{k}: 필수 필드 누락\")\n",
|
|
" else:\n",
|
|
" errs += check(f\"labels[{i}].{k}\", lab[k], rule)\n",
|
|
" return errs\n",
|
|
"\n",
|
|
"\n",
|
|
"# 2) [교육용가상자료] 샘플 메타데이터 20건 생성\n",
|
|
"rng = np.random.default_rng(1)\n",
|
|
"classes = SCHEMA[\"label\"][\"class\"][\"enum\"]\n",
|
|
"records = []\n",
|
|
"for i in range(20):\n",
|
|
" labels = [{\"class\": str(rng.choice(classes)), \"frame\": int(f),\n",
|
|
" \"bbox\": [int(rng.integers(0, 60)), int(rng.integers(0, 60)), 3, 3],\n",
|
|
" \"thermal\": float(round(rng.uniform(100, 240), 1))} for f in range(3)]\n",
|
|
" records.append({\"sequence_id\": f\"SEQ{i:03d}\", \"n_frames\": 30,\n",
|
|
" \"sensor\": {\"type\": str(rng.choice([\"EO\", \"IR\"])), \"resolution\": [64, 64], \"fps\": 10},\n",
|
|
" \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"night\", \"fog\", \"rain\", \"smoke\"])),\n",
|
|
" \"position_error_m\": float(round(rng.uniform(2, 30), 1)),\n",
|
|
" \"comm_delay_ms\": int(rng.integers(20, 400))},\n",
|
|
" \"labels\": labels})\n",
|
|
"\n",
|
|
"# 3) 정상 데이터 검증\n",
|
|
"ok = sum(1 for r in records if not validate(r))\n",
|
|
"print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n",
|
|
"\n",
|
|
"# 4) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n",
|
|
"bad = json.loads(json.dumps(records[0]))\n",
|
|
"bad[\"sequence_id\"] = \"SEQ-1\"\n",
|
|
"bad[\"sensor\"][\"fps\"] = 120\n",
|
|
"del bad[\"tactical\"][\"comm_delay_ms\"]\n",
|
|
"bad[\"labels\"][0][\"class\"] = \"ufo\"\n",
|
|
"bad[\"labels\"][1][\"bbox\"] = [1, 2, 3]\n",
|
|
"print(\"\\n[오류 주입 결과]\")\n",
|
|
"for e in validate(bad):\n",
|
|
" print(\" -\", e)\n",
|
|
"\n",
|
|
"# 5) 스키마 필드 정의서(명세서 초안)\n",
|
|
"rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n",
|
|
"for g in (\"sensor\", \"tactical\", \"label\"):\n",
|
|
" for k, r in SCHEMA[g].items():\n",
|
|
" t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n",
|
|
" rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n",
|
|
"spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n",
|
|
"print(\"\\n[데이터 명세 요약]\")\n",
|
|
"print(spec.to_string(index=False))\n",
|
|
"print(\"\\n클래스별 라벨 수:\", pd.Series([l[\"class\"] for r in records for l in r[\"labels\"]]).value_counts().to_dict())\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"정상 샘플 20건 중 검증 통과 20건, 오류 0건\n",
|
|
"\n",
|
|
"[오류 주입 결과]\n",
|
|
" - sequence_id: 형식 불일치 'SEQ-1'\n",
|
|
" - sensor.fps: 최대값 60 초과\n",
|
|
" - tactical.comm_delay_ms: 필수 필드 누락\n",
|
|
" - labels[0].class: 허용되지 않은 값 'ufo'\n",
|
|
" - labels[1].bbox: 길이 4 필요\n",
|
|
"\n",
|
|
"[데이터 명세 요약]\n",
|
|
" 그룹 필드 자료형 규칙\n",
|
|
" 기본 sequence_id str {'pattern': '^SEQ\\\\d{3}$'}\n",
|
|
" 기본 n_frames int {'min': 1, 'max': 3000}\n",
|
|
" sensor type str {'enum': ['EO', 'IR']}\n",
|
|
" sensor resolution list {'length': 2}\n",
|
|
" sensor fps number {'min': 1, 'max': 60}\n",
|
|
"tactical weather str {'enum': ['clear', 'night', 'fog', 'rain', 'smoke']}\n",
|
|
"tactical position_error_m number {'min': 0, 'max': 500}\n",
|
|
"tactical comm_delay_ms number {'min': 0, 'max': 10000}\n",
|
|
" label class str {'enum': ['recon_drone', 'attack_drone', 'cruise_missile', 'bird', 'civil_aircraft', 'clutter']}\n",
|
|
" label bbox list {'length': 4}\n",
|
|
" label frame int {'min': 0}\n",
|
|
" label thermal number {'min': 0, 'max': 255}\n",
|
|
"\n",
|
|
"클래스별 라벨 수: {'bird': 12, 'recon_drone': 12, 'cruise_missile': 10, 'clutter': 10, 'attack_drone': 8, 'civil_aircraft': 8}\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 02. 적외선·광학(EO/IR) 전장 영상 노이즈 제거 및 기상 왜곡 보정 전처리 모듈 구현\n",
|
|
"**소요시간**: 약 2.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"전장 악천후(안개, 연막, 강우) 및 센서 진동으로 발생하는 영상 노이즈를 필터링하고 명암비 강화(CLAHE) 및 프레임 정규화 전처리 파이프라인을 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **안개·강우·야간 등 기상 왜곡과 센서 잡음이 섞인 영상을 조건별로 보정하는 전처리 파이프라인을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **중간값 필터(잡음), 빗줄기 제거(가로 방향 형태학적 열림), 명암비 강화(CLAHE 방식), 0~1 정규화, 결측 프레임 보정을 구현하고 기상 조건별로 조합하여 최적화하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/preprocess/ops01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"안개·강우·야간 등 기상 왜곡과 센서 잡음이 섞인 EO/IR 영상을 조건별로 전처리(잡음 제거·빗줄기 제거·명암비 강화·정규화)하고, 처리 속도와 탐지 품질 개선 효과를 확인한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)\n",
|
|
"# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"H = W = 64\n",
|
|
"PROFILE = {\"drone\": ((2, 3), (170, 225)), \"bird\": ((2, 3), (110, 150)),\n",
|
|
" \"civil\": ((4, 6), (190, 240)), \"clutter\": ((2, 4), (140, 190))}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _new_obj(cls, rng, dive=False, v=None, xy=None):\n",
|
|
" (s0, s1), (h0, h1) = PROFILE[cls]\n",
|
|
" x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))\n",
|
|
" speed = {\"drone\": 1.3, \"bird\": 0.9, \"civil\": 0.35, \"clutter\": 0.0}[cls]\n",
|
|
" vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)\n",
|
|
" return {\"cls\": cls, \"size\": int(rng.integers(s0, s1 + 1)), \"heat\": float(rng.uniform(h0, h1)),\n",
|
|
" \"x\": float(x), \"y\": float(y), \"vx\": float(vx), \"vy\": float(vy), \"dive\": dive}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _noise(img, cond, rng):\n",
|
|
" f = img.astype(float)\n",
|
|
" if cond == \"night\":\n",
|
|
" f = f * 0.8 + rng.normal(0, 14, f.shape)\n",
|
|
" elif cond == \"fog\":\n",
|
|
" f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)\n",
|
|
" elif cond == \"rain\":\n",
|
|
" f = f + rng.normal(0, 8, f.shape)\n",
|
|
" for _ in range(20):\n",
|
|
" x, y0 = rng.integers(0, W), rng.integers(0, H - 8)\n",
|
|
" f[y0:y0 + 8, x] += 45\n",
|
|
" else:\n",
|
|
" f = f + rng.normal(0, 6, f.shape)\n",
|
|
" return np.clip(f, 0, 255).astype(np.uint8)\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_sequence(seq_id, cond, rng, kind=\"mixed\", n_frames=30, n_drone=None, n_bird=None):\n",
|
|
" \"\"\"시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation\"\"\"\n",
|
|
" objs = []\n",
|
|
" if kind == \"swarm\":\n",
|
|
" v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)\n",
|
|
" objs = [_new_obj(\"drone\", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]\n",
|
|
" objs.append(_new_obj(\"bird\", rng))\n",
|
|
" elif kind == \"dive\":\n",
|
|
" d = _new_obj(\"drone\", rng, dive=True)\n",
|
|
" d[\"y\"] = float(rng.uniform(4, 14))\n",
|
|
" objs = [d, _new_obj(\"bird\", rng), _new_obj(\"clutter\", rng)]\n",
|
|
" elif kind == \"saturation\":\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(n_drone)] + [_new_obj(\"bird\", rng) for _ in range(n_bird)]\n",
|
|
" else:\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(int(rng.integers(1, 3)))]\n",
|
|
" objs += [_new_obj(\"bird\", rng) for _ in range(int(rng.integers(0, 3)))]\n",
|
|
" if rng.random() < 0.5:\n",
|
|
" objs.append(_new_obj(\"civil\", rng))\n",
|
|
" if rng.random() < 0.6:\n",
|
|
" objs.append(_new_obj(\"clutter\", rng))\n",
|
|
" frames, labels = np.zeros((n_frames, H, W), np.uint8), []\n",
|
|
" base = rng.normal(40, 6, (H, W))\n",
|
|
" for t in range(n_frames):\n",
|
|
" canvas = base.copy()\n",
|
|
" for i, o in enumerate(objs):\n",
|
|
" if t > 0:\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" o[\"vx\"] = float(np.clip(o[\"vx\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" o[\"vy\"] = float(np.clip(o[\"vy\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" if o[\"dive\"] and t > 10:\n",
|
|
" o[\"vy\"] += 0.18\n",
|
|
" o[\"x\"] = float(np.clip(o[\"x\"] + o[\"vx\"], 1, W - o[\"size\"] - 1))\n",
|
|
" o[\"y\"] = float(np.clip(o[\"y\"] + o[\"vy\"], 1, H - o[\"size\"] - 1))\n",
|
|
" heat = o[\"heat\"]\n",
|
|
" if o[\"cls\"] == \"clutter\" and rng.random() < 0.3:\n",
|
|
" heat *= 0.7\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" heat *= float(rng.uniform(0.85, 1.1))\n",
|
|
" x, y, s = int(round(o[\"x\"])), int(round(o[\"y\"])), o[\"size\"]\n",
|
|
" canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)\n",
|
|
" labels.append({\"frame\": t, \"track\": f\"{seq_id}_T{i:02d}\", \"class\": o[\"cls\"], \"bbox\": [x, y, s, s],\n",
|
|
" \"pattern\": \"dive\" if o[\"dive\"] else (\"swarm\" if kind == \"swarm\" and o[\"cls\"] == \"drone\" else \"normal\")})\n",
|
|
" frames[t] = _noise(canvas, cond, rng)\n",
|
|
" return frames, labels\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=24, seed=7):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" conds = [\"clear\", \"night\", \"fog\", \"rain\"]\n",
|
|
" out = []\n",
|
|
" for i in range(n):\n",
|
|
" kind = \"swarm\" if i % 8 == 5 else (\"dive\" if i % 8 == 6 else \"mixed\")\n",
|
|
" f, lab = make_sequence(f\"SEQ{i:03d}\", conds[i % 4], rng, kind)\n",
|
|
" out.append({\"id\": f\"SEQ{i:03d}\", \"cond\": conds[i % 4], \"kind\": kind, \"frames\": f, \"labels\": lab})\n",
|
|
" return out\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"\n",
|
|
"def denoise(f, size):\n",
|
|
" return f if size <= 1 else ndimage.median_filter(f, size=size)\n",
|
|
"\n",
|
|
"\n",
|
|
"def remove_rain(f):\n",
|
|
" return ndimage.grey_opening(f, size=(1, 2))\n",
|
|
"\n",
|
|
"\n",
|
|
"def clahe_like(f, tiles=4, clip=0.02):\n",
|
|
" \"\"\"타일별 히스토그램 평활화 후 전체 대비 확장(간이 CLAHE). 안개로 떨어진 국소 대비를 살린다.\"\"\"\n",
|
|
" out = np.zeros_like(f, dtype=float)\n",
|
|
" th, tw = f.shape[0] // tiles, f.shape[1] // tiles\n",
|
|
" for i in range(tiles):\n",
|
|
" for j in range(tiles):\n",
|
|
" t = f[i * th:(i + 1) * th, j * tw:(j + 1) * tw].astype(float)\n",
|
|
" hist, edges = np.histogram(t, bins=64, range=(0, 256))\n",
|
|
" limit = max(1, int(clip * t.size))\n",
|
|
" hist = np.minimum(hist, limit)\n",
|
|
" cdf = np.cumsum(hist) / hist.sum()\n",
|
|
" out[i * th:(i + 1) * th, j * tw:(j + 1) * tw] = np.interp(t, edges[:-1], cdf) * 255\n",
|
|
" return 0.5 * out + 0.5 * stretch(f)\n",
|
|
"\n",
|
|
"\n",
|
|
"def stretch(f):\n",
|
|
" lo, hi = np.percentile(f, [1, 99.5])\n",
|
|
" return np.clip((f.astype(float) - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n",
|
|
"\n",
|
|
"\n",
|
|
"def normalize(f):\n",
|
|
" return f.astype(np.float32) / 255.0\n",
|
|
"\n",
|
|
"\n",
|
|
"PARAMS = {\"clear\": dict(median=1, rain=False), \"night\": dict(median=1, rain=False),\n",
|
|
" \"fog\": dict(median=1, rain=False), \"rain\": dict(median=1, rain=True)}\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(frame, cond):\n",
|
|
" p = PARAMS[cond]\n",
|
|
" f = denoise(frame.astype(float), p[\"median\"])\n",
|
|
" if p[\"rain\"]:\n",
|
|
" f = remove_rain(f)\n",
|
|
" return stretch(f)\n",
|
|
"\n",
|
|
"\n",
|
|
"def quick_f1(seqs, use_pre, k=2.5):\n",
|
|
" tp = fp = fn = 0\n",
|
|
" for s in seqs:\n",
|
|
" for t, fr in enumerate(s[\"frames\"]):\n",
|
|
" img = preprocess(fr, s[\"cond\"]) if use_pre else fr.astype(float)\n",
|
|
" lab, n = ndimage.label(img > img.mean() + k * img.std())\n",
|
|
" sizes = ndimage.sum(np.ones_like(img), lab, range(1, n + 1))\n",
|
|
" n_det = int(((sizes >= 3) & (sizes <= 60)).sum())\n",
|
|
" n_gt = sum(1 for l in s[\"labels\"] if l[\"frame\"] == t)\n",
|
|
" tp += min(n_det, n_gt); fp += max(0, n_det - n_gt); fn += max(0, n_gt - n_det)\n",
|
|
" p, r = tp / max(tp + fp, 1), tp / max(tp + fn, 1)\n",
|
|
" return 2 * p * r / max(p + r, 1e-9)\n",
|
|
"\n",
|
|
"\n",
|
|
"data = make_dataset(24, seed=7)\n",
|
|
"\n",
|
|
"# 1) 처리 속도 측정 (프레임/초)\n",
|
|
"frames = [(fr, s[\"cond\"]) for s in data for fr in s[\"frames\"]]\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"for fr, c in frames:\n",
|
|
" normalize(preprocess(fr, c))\n",
|
|
"dt = time.perf_counter() - t0\n",
|
|
"print(f\"전처리 {len(frames)}프레임, {len(frames) / dt:,.0f} 프레임/초, 오류 0건\")\n",
|
|
"\n",
|
|
"# 2) 조건별 전처리 전후 탐지 F1 비교\n",
|
|
"print(\"\\n조건 전처리 전 F1 전처리 후 F1\")\n",
|
|
"for c in [\"clear\", \"night\", \"fog\", \"rain\"]:\n",
|
|
" sub = [s for s in data if s[\"cond\"] == c]\n",
|
|
" print(f\"{c:6s} {quick_f1(sub, False):10.3f} {quick_f1(sub, True):10.3f}\")\n",
|
|
"\n",
|
|
"# 3) 필터를 과하게 쓰면 소형 표적이 사라지는지 확인 (3x3 중간값 필터)\n",
|
|
"PARAMS[\"night\"][\"median\"] = 3\n",
|
|
"night = [s for s in data if s[\"cond\"] == \"night\"]\n",
|
|
"print(f\"\\n야간 3x3 중간값 필터 적용 시 F1: {quick_f1(night, True):.3f} (2px 소형 드론이 함께 지워짐)\")\n",
|
|
"PARAMS[\"night\"][\"median\"] = 1\n",
|
|
"\n",
|
|
"# 4) 결측 프레임 보정(앞 프레임으로 채우기)\n",
|
|
"seq = list(data[3][\"frames\"]); seq[5] = None; seq[6] = None\n",
|
|
"filled = list(seq)\n",
|
|
"for i in range(len(filled)):\n",
|
|
" if filled[i] is None:\n",
|
|
" filled[i] = filled[i - 1]\n",
|
|
"print(\"결측 프레임 2장 보정 완료:\", all(f is not None for f in filled))\n",
|
|
"\n",
|
|
"# 5) 전후 비교 그림\n",
|
|
"fig, ax = plt.subplots(2, 4, figsize=(12, 6))\n",
|
|
"for j, c in enumerate([\"clear\", \"night\", \"fog\", \"rain\"]):\n",
|
|
" s = next(x for x in data if x[\"cond\"] == c)\n",
|
|
" ax[0, j].imshow(s[\"frames\"][0], cmap=\"gray\"); ax[0, j].set_title(f\"{c} raw\")\n",
|
|
" ax[1, j].imshow(clahe_like(s[\"frames\"][0]) if c == \"fog\" else preprocess(s[\"frames\"][0], c), cmap=\"gray\")\n",
|
|
" ax[1, j].set_title(f\"{c} preprocessed\")\n",
|
|
"for a in ax.ravel():\n",
|
|
" a.axis(\"off\")\n",
|
|
"plt.suptitle(\"Synthetic IR frames: before / after preprocessing (training data)\")\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"전처리 720프레임, 3,422 프레임/초, 오류 0건\n",
|
|
"\n",
|
|
"조건 전처리 전 F1 전처리 후 F1\n",
|
|
"clear 0.956 0.958\n",
|
|
"night 0.986 0.988\n",
|
|
"fog 0.989 0.991\n",
|
|
"rain 0.500 0.838\n",
|
|
"\n",
|
|
"야간 3x3 중간값 필터 적용 시 F1: 0.857 (2px 소형 드론이 함께 지워짐)\n",
|
|
"결측 프레임 2장 보정 완료: True\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 03. YOLO/규칙 기반 저고도 공중위협 객체 탐지 Baseline 프로토타입 구현\n",
|
|
"**소요시간**: 약 2.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"저고도 공중 비행 객체를 신속하게 1차 식별하기 위한 YOLO 경량 비전 모델 및 규칙 기반 바운딩 박스 추적 Baseline 프로토타입을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **저고도 공중 객체를 신속하게 1차 식별하는 규칙 기반 탐지 Baseline을 구현하고, 경량 YOLO를 쓸 경우와 장단점을 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **밝기 임계값(평균 + k x 표준편차)과 연결 영역 분석으로 바운딩 박스를 만들고 IoU 매칭으로 정밀도·재현율을 기상 조건별로 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/models/ops01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"규칙 기반(밝기 임계값 + 연결 영역 분석) 저고도 공중위협 객체 탐지 Baseline을 구현하고, IoU 매칭으로 탐지 정확도(정밀도·재현율)를 측정한다. 경량 YOLO를 쓸 경우와의 차이도 함께 비교한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from matplotlib.patches import Rectangle\n",
|
|
"from scipy import ndimage\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)\n",
|
|
"# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"H = W = 64\n",
|
|
"PROFILE = {\"drone\": ((2, 3), (170, 225)), \"bird\": ((2, 3), (110, 150)),\n",
|
|
" \"civil\": ((4, 6), (190, 240)), \"clutter\": ((2, 4), (140, 190))}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _new_obj(cls, rng, dive=False, v=None, xy=None):\n",
|
|
" (s0, s1), (h0, h1) = PROFILE[cls]\n",
|
|
" x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))\n",
|
|
" speed = {\"drone\": 1.3, \"bird\": 0.9, \"civil\": 0.35, \"clutter\": 0.0}[cls]\n",
|
|
" vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)\n",
|
|
" return {\"cls\": cls, \"size\": int(rng.integers(s0, s1 + 1)), \"heat\": float(rng.uniform(h0, h1)),\n",
|
|
" \"x\": float(x), \"y\": float(y), \"vx\": float(vx), \"vy\": float(vy), \"dive\": dive}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _noise(img, cond, rng):\n",
|
|
" f = img.astype(float)\n",
|
|
" if cond == \"night\":\n",
|
|
" f = f * 0.8 + rng.normal(0, 14, f.shape)\n",
|
|
" elif cond == \"fog\":\n",
|
|
" f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)\n",
|
|
" elif cond == \"rain\":\n",
|
|
" f = f + rng.normal(0, 8, f.shape)\n",
|
|
" for _ in range(20):\n",
|
|
" x, y0 = rng.integers(0, W), rng.integers(0, H - 8)\n",
|
|
" f[y0:y0 + 8, x] += 45\n",
|
|
" else:\n",
|
|
" f = f + rng.normal(0, 6, f.shape)\n",
|
|
" return np.clip(f, 0, 255).astype(np.uint8)\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_sequence(seq_id, cond, rng, kind=\"mixed\", n_frames=30, n_drone=None, n_bird=None):\n",
|
|
" \"\"\"시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation\"\"\"\n",
|
|
" objs = []\n",
|
|
" if kind == \"swarm\":\n",
|
|
" v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)\n",
|
|
" objs = [_new_obj(\"drone\", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]\n",
|
|
" objs.append(_new_obj(\"bird\", rng))\n",
|
|
" elif kind == \"dive\":\n",
|
|
" d = _new_obj(\"drone\", rng, dive=True)\n",
|
|
" d[\"y\"] = float(rng.uniform(4, 14))\n",
|
|
" objs = [d, _new_obj(\"bird\", rng), _new_obj(\"clutter\", rng)]\n",
|
|
" elif kind == \"saturation\":\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(n_drone)] + [_new_obj(\"bird\", rng) for _ in range(n_bird)]\n",
|
|
" else:\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(int(rng.integers(1, 3)))]\n",
|
|
" objs += [_new_obj(\"bird\", rng) for _ in range(int(rng.integers(0, 3)))]\n",
|
|
" if rng.random() < 0.5:\n",
|
|
" objs.append(_new_obj(\"civil\", rng))\n",
|
|
" if rng.random() < 0.6:\n",
|
|
" objs.append(_new_obj(\"clutter\", rng))\n",
|
|
" frames, labels = np.zeros((n_frames, H, W), np.uint8), []\n",
|
|
" base = rng.normal(40, 6, (H, W))\n",
|
|
" for t in range(n_frames):\n",
|
|
" canvas = base.copy()\n",
|
|
" for i, o in enumerate(objs):\n",
|
|
" if t > 0:\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" o[\"vx\"] = float(np.clip(o[\"vx\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" o[\"vy\"] = float(np.clip(o[\"vy\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" if o[\"dive\"] and t > 10:\n",
|
|
" o[\"vy\"] += 0.18\n",
|
|
" o[\"x\"] = float(np.clip(o[\"x\"] + o[\"vx\"], 1, W - o[\"size\"] - 1))\n",
|
|
" o[\"y\"] = float(np.clip(o[\"y\"] + o[\"vy\"], 1, H - o[\"size\"] - 1))\n",
|
|
" heat = o[\"heat\"]\n",
|
|
" if o[\"cls\"] == \"clutter\" and rng.random() < 0.3:\n",
|
|
" heat *= 0.7\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" heat *= float(rng.uniform(0.85, 1.1))\n",
|
|
" x, y, s = int(round(o[\"x\"])), int(round(o[\"y\"])), o[\"size\"]\n",
|
|
" canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)\n",
|
|
" labels.append({\"frame\": t, \"track\": f\"{seq_id}_T{i:02d}\", \"class\": o[\"cls\"], \"bbox\": [x, y, s, s],\n",
|
|
" \"pattern\": \"dive\" if o[\"dive\"] else (\"swarm\" if kind == \"swarm\" and o[\"cls\"] == \"drone\" else \"normal\")})\n",
|
|
" frames[t] = _noise(canvas, cond, rng)\n",
|
|
" return frames, labels\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=24, seed=7):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" conds = [\"clear\", \"night\", \"fog\", \"rain\"]\n",
|
|
" out = []\n",
|
|
" for i in range(n):\n",
|
|
" kind = \"swarm\" if i % 8 == 5 else (\"dive\" if i % 8 == 6 else \"mixed\")\n",
|
|
" f, lab = make_sequence(f\"SEQ{i:03d}\", conds[i % 4], rng, kind)\n",
|
|
" out.append({\"id\": f\"SEQ{i:03d}\", \"cond\": conds[i % 4], \"kind\": kind, \"frames\": f, \"labels\": lab})\n",
|
|
" return out\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리·탐지 공통 함수 ----\n",
|
|
"def preprocess(frame, cond):\n",
|
|
" f = frame.astype(float)\n",
|
|
" if cond == \"rain\": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)\n",
|
|
" f = ndimage.grey_opening(f, size=(1, 2))\n",
|
|
" lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장\n",
|
|
" return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n",
|
|
"\n",
|
|
"\n",
|
|
"def detect(frame, cond, k=2.0, min_area=3, max_area=60):\n",
|
|
" img = preprocess(frame, cond)\n",
|
|
" lab, _ = ndimage.label(img > img.mean() + k * img.std())\n",
|
|
" dets = []\n",
|
|
" for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n",
|
|
" area = int((lab[sl] == i).sum())\n",
|
|
" if min_area <= area <= max_area:\n",
|
|
" y, x = sl[0].start, sl[1].start\n",
|
|
" dets.append({\"bbox\": [x, y, sl[1].stop - x, sl[0].stop - y], \"area\": area,\n",
|
|
" \"thermal\": float(frame[sl][lab[sl] == i].max())})\n",
|
|
" return dets\n",
|
|
"\n",
|
|
"\n",
|
|
"def iou(a, b):\n",
|
|
" ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))\n",
|
|
" iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))\n",
|
|
" inter = ix * iy\n",
|
|
" return inter / (a[2] * a[3] + b[2] * b[3] - inter)\n",
|
|
"\n",
|
|
"\n",
|
|
"def evaluate(data, k=2.0, classes=(\"drone\", \"bird\", \"civil\", \"clutter\")):\n",
|
|
" tp = fp = fn = 0\n",
|
|
" for s in data:\n",
|
|
" for t, fr in enumerate(s[\"frames\"]):\n",
|
|
" gts = [l[\"bbox\"] for l in s[\"labels\"] if l[\"frame\"] == t and l[\"class\"] in classes]\n",
|
|
" used = set()\n",
|
|
" dets = detect(fr, s[\"cond\"], k)\n",
|
|
" for d in dets:\n",
|
|
" best = max(((iou(d[\"bbox\"], g), j) for j, g in enumerate(gts) if j not in used), default=(0, -1))\n",
|
|
" if best[0] >= 0.1:\n",
|
|
" tp += 1; used.add(best[1])\n",
|
|
" fp += len(dets) - len(used); fn += len(gts) - len(used)\n",
|
|
" p, r = tp / max(tp + fp, 1), tp / max(tp + fn, 1)\n",
|
|
" return {\"TP\": tp, \"FP\": fp, \"FN\": fn, \"정밀도\": round(p, 3), \"재현율\": round(r, 3), \"F1\": round(2 * p * r / max(p + r, 1e-9), 3)}\n",
|
|
"\n",
|
|
"\n",
|
|
"data = make_dataset(24, seed=7)\n",
|
|
"\n",
|
|
"# 1) 기상 조건별 객체 탐지 성능 (드론·조류·민간기·클러터 모두 '객체'로 봄)\n",
|
|
"rows = []\n",
|
|
"for c in [\"clear\", \"night\", \"fog\", \"rain\"]:\n",
|
|
" rows.append({\"조건\": c, **evaluate([s for s in data if s[\"cond\"] == c])})\n",
|
|
"rows.append({\"조건\": \"전체\", **evaluate(data)})\n",
|
|
"print(pd.DataFrame(rows).to_string(index=False))\n",
|
|
"acc = rows[-1][\"재현율\"]\n",
|
|
"print(f\"\\n목표(탐지 정확도 75% 이상) 달성 여부: {'달성' if acc >= 0.75 else '미달'} (재현율 {acc:.1%})\")\n",
|
|
"\n",
|
|
"# 2) 드론만 기준으로 보면? -> 조류·민간기까지 잡혀 정밀도가 크게 떨어진다 (미션 4·5의 동기)\n",
|
|
"d = evaluate(data, classes=(\"drone\",))\n",
|
|
"print(f\"드론만 정답으로 볼 때 정밀도 {d['정밀도']:.1%}, 재현율 {d['재현율']:.1%}\")\n",
|
|
"\n",
|
|
"# 3) 임계값 k에 따른 민감도 변화\n",
|
|
"print(\"\\nk 정밀도 재현율\")\n",
|
|
"for k in [1.5, 2.0, 2.5, 3.0, 3.5]:\n",
|
|
" e = evaluate(data, k)\n",
|
|
" print(f\"{k:<4} {e['정밀도']:.3f} {e['재현율']:.3f}\")\n",
|
|
"\n",
|
|
"# 4) 규칙 기반 vs 경량 YOLO 비교표\n",
|
|
"print(pd.DataFrame({\"항목\": [\"학습 데이터\", \"연산 자원\", \"소형 표적(2~3px)\", \"판단 근거 설명\", \"도입 속도\"],\n",
|
|
" \"규칙 기반(본 Baseline)\": [\"불필요\", \"CPU로 충분\", \"밝기 차이에 의존\", \"쉬움(임계값)\", \"즉시\"],\n",
|
|
" \"경량 YOLO\": [\"라벨 수천 장 이상\", \"GPU 권장\", \"학습 데이터 품질에 의존\", \"어려움\", \"학습 후\"]}).to_string(index=False))\n",
|
|
"\n",
|
|
"# 5) 탐지 결과 시각화\n",
|
|
"s = data[5]\n",
|
|
"fig, ax = plt.subplots(1, 3, figsize=(12, 4))\n",
|
|
"for a, t in zip(ax, [0, 10, 20]):\n",
|
|
" a.imshow(s[\"frames\"][t], cmap=\"gray\")\n",
|
|
" for det in detect(s[\"frames\"][t], s[\"cond\"]):\n",
|
|
" x, y, w, h = det[\"bbox\"]\n",
|
|
" a.add_patch(Rectangle((x - .5, y - .5), w, h, fill=False, color=\"red\"))\n",
|
|
" a.set_title(f\"{s['id']} frame {t} (red = detection)\")\n",
|
|
" a.axis(\"off\")\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
" 조건 TP FP FN 정밀도 재현율 F1\n",
|
|
"clear 767 0 43 1.000 0.947 0.973\n",
|
|
"night 799 10 11 0.988 0.986 0.987\n",
|
|
" fog 561 28 9 0.952 0.984 0.968\n",
|
|
" rain 591 391 9 0.602 0.985 0.747\n",
|
|
" 전체 2718 429 72 0.864 0.974 0.916\n",
|
|
"\n",
|
|
"목표(탐지 정확도 75% 이상) 달성 여부: 달성 (재현율 97.4%)\n",
|
|
"드론만 정답으로 볼 때 정밀도 39.8%, 재현율 99.4%\n",
|
|
"\n",
|
|
"k 정밀도 재현율\n",
|
|
"1.5 0.569 0.976\n",
|
|
"2.0 0.864 0.974\n",
|
|
"2.5 0.920 0.963\n",
|
|
"3.0 0.933 0.878\n",
|
|
"3.5 0.951 0.764\n",
|
|
" 항목 규칙 기반(본 Baseline) 경량 YOLO\n",
|
|
" 학습 데이터 불필요 라벨 수천 장 이상\n",
|
|
" 연산 자원 CPU로 충분 GPU 권장\n",
|
|
"소형 표적(2~3px) 밝기 차이에 의존 학습 데이터 품질에 의존\n",
|
|
" 판단 근거 설명 쉬움(임계값) 어려움\n",
|
|
" 도입 속도 즉시 학습 후\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 04. 군집 드론 및 비행체 기만 비행 패턴 식별 복합 위협 탐지 모델 고도화\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"적 군집 드론(Swarm)의 분산 비행, 급강하 자폭 궤적 및 비행체 기만 비행 패턴을 식별하기 위한 다중 객체 딥러닝 탐지 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **군집 드론의 동행 비행, 급강하 자폭 궤적, 조류처럼 흔들리는 기만 비행 패턴을 식별하도록 탐지를 궤적 단위로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **예측 위치 기반 최근접 이웃 추적기로 프레임별 탐지를 궤적으로 잇고, 속도·방향 전환·직진성·수직 가속·크기·열신호 특징을 계산해 정속 직진·급강하·불규칙·군집 동행 패턴 판정하고 정확도를 측정하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/models/ops01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"프레임별 탐지를 궤적으로 연결(추적)하고, 궤적의 움직임 특징으로 군집 드론(동일 속도 동행)·급강하 자폭 궤적·불규칙(조류형 기만) 비행 패턴을 식별해 복합 위협 탐지 재현율과 처리 지연을 측정한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)\n",
|
|
"# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"H = W = 64\n",
|
|
"PROFILE = {\"drone\": ((2, 3), (170, 225)), \"bird\": ((2, 3), (110, 150)),\n",
|
|
" \"civil\": ((4, 6), (190, 240)), \"clutter\": ((2, 4), (140, 190))}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _new_obj(cls, rng, dive=False, v=None, xy=None):\n",
|
|
" (s0, s1), (h0, h1) = PROFILE[cls]\n",
|
|
" x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))\n",
|
|
" speed = {\"drone\": 1.3, \"bird\": 0.9, \"civil\": 0.35, \"clutter\": 0.0}[cls]\n",
|
|
" vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)\n",
|
|
" return {\"cls\": cls, \"size\": int(rng.integers(s0, s1 + 1)), \"heat\": float(rng.uniform(h0, h1)),\n",
|
|
" \"x\": float(x), \"y\": float(y), \"vx\": float(vx), \"vy\": float(vy), \"dive\": dive}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _noise(img, cond, rng):\n",
|
|
" f = img.astype(float)\n",
|
|
" if cond == \"night\":\n",
|
|
" f = f * 0.8 + rng.normal(0, 14, f.shape)\n",
|
|
" elif cond == \"fog\":\n",
|
|
" f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)\n",
|
|
" elif cond == \"rain\":\n",
|
|
" f = f + rng.normal(0, 8, f.shape)\n",
|
|
" for _ in range(20):\n",
|
|
" x, y0 = rng.integers(0, W), rng.integers(0, H - 8)\n",
|
|
" f[y0:y0 + 8, x] += 45\n",
|
|
" else:\n",
|
|
" f = f + rng.normal(0, 6, f.shape)\n",
|
|
" return np.clip(f, 0, 255).astype(np.uint8)\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_sequence(seq_id, cond, rng, kind=\"mixed\", n_frames=30, n_drone=None, n_bird=None):\n",
|
|
" \"\"\"시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation\"\"\"\n",
|
|
" objs = []\n",
|
|
" if kind == \"swarm\":\n",
|
|
" v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)\n",
|
|
" objs = [_new_obj(\"drone\", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]\n",
|
|
" objs.append(_new_obj(\"bird\", rng))\n",
|
|
" elif kind == \"dive\":\n",
|
|
" d = _new_obj(\"drone\", rng, dive=True)\n",
|
|
" d[\"y\"] = float(rng.uniform(4, 14))\n",
|
|
" objs = [d, _new_obj(\"bird\", rng), _new_obj(\"clutter\", rng)]\n",
|
|
" elif kind == \"saturation\":\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(n_drone)] + [_new_obj(\"bird\", rng) for _ in range(n_bird)]\n",
|
|
" else:\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(int(rng.integers(1, 3)))]\n",
|
|
" objs += [_new_obj(\"bird\", rng) for _ in range(int(rng.integers(0, 3)))]\n",
|
|
" if rng.random() < 0.5:\n",
|
|
" objs.append(_new_obj(\"civil\", rng))\n",
|
|
" if rng.random() < 0.6:\n",
|
|
" objs.append(_new_obj(\"clutter\", rng))\n",
|
|
" frames, labels = np.zeros((n_frames, H, W), np.uint8), []\n",
|
|
" base = rng.normal(40, 6, (H, W))\n",
|
|
" for t in range(n_frames):\n",
|
|
" canvas = base.copy()\n",
|
|
" for i, o in enumerate(objs):\n",
|
|
" if t > 0:\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" o[\"vx\"] = float(np.clip(o[\"vx\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" o[\"vy\"] = float(np.clip(o[\"vy\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" if o[\"dive\"] and t > 10:\n",
|
|
" o[\"vy\"] += 0.18\n",
|
|
" o[\"x\"] = float(np.clip(o[\"x\"] + o[\"vx\"], 1, W - o[\"size\"] - 1))\n",
|
|
" o[\"y\"] = float(np.clip(o[\"y\"] + o[\"vy\"], 1, H - o[\"size\"] - 1))\n",
|
|
" heat = o[\"heat\"]\n",
|
|
" if o[\"cls\"] == \"clutter\" and rng.random() < 0.3:\n",
|
|
" heat *= 0.7\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" heat *= float(rng.uniform(0.85, 1.1))\n",
|
|
" x, y, s = int(round(o[\"x\"])), int(round(o[\"y\"])), o[\"size\"]\n",
|
|
" canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)\n",
|
|
" labels.append({\"frame\": t, \"track\": f\"{seq_id}_T{i:02d}\", \"class\": o[\"cls\"], \"bbox\": [x, y, s, s],\n",
|
|
" \"pattern\": \"dive\" if o[\"dive\"] else (\"swarm\" if kind == \"swarm\" and o[\"cls\"] == \"drone\" else \"normal\")})\n",
|
|
" frames[t] = _noise(canvas, cond, rng)\n",
|
|
" return frames, labels\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=24, seed=7):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" conds = [\"clear\", \"night\", \"fog\", \"rain\"]\n",
|
|
" out = []\n",
|
|
" for i in range(n):\n",
|
|
" kind = \"swarm\" if i % 8 == 5 else (\"dive\" if i % 8 == 6 else \"mixed\")\n",
|
|
" f, lab = make_sequence(f\"SEQ{i:03d}\", conds[i % 4], rng, kind)\n",
|
|
" out.append({\"id\": f\"SEQ{i:03d}\", \"cond\": conds[i % 4], \"kind\": kind, \"frames\": f, \"labels\": lab})\n",
|
|
" return out\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리·탐지 공통 함수 ----\n",
|
|
"def preprocess(frame, cond):\n",
|
|
" f = frame.astype(float)\n",
|
|
" if cond == \"rain\": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)\n",
|
|
" f = ndimage.grey_opening(f, size=(1, 2))\n",
|
|
" lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장\n",
|
|
" return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n",
|
|
"\n",
|
|
"\n",
|
|
"def detect(frame, cond, k=2.0, min_area=3, max_area=60):\n",
|
|
" img = preprocess(frame, cond)\n",
|
|
" lab, _ = ndimage.label(img > img.mean() + k * img.std())\n",
|
|
" dets = []\n",
|
|
" for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n",
|
|
" area = int((lab[sl] == i).sum())\n",
|
|
" if min_area <= area <= max_area:\n",
|
|
" y, x = sl[0].start, sl[1].start\n",
|
|
" dets.append({\"bbox\": [x, y, sl[1].stop - x, sl[0].stop - y], \"area\": area,\n",
|
|
" \"thermal\": float(frame[sl][lab[sl] == i].max())})\n",
|
|
" return dets\n",
|
|
"\n",
|
|
"\n",
|
|
"def iou(a, b):\n",
|
|
" ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))\n",
|
|
" iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))\n",
|
|
" inter = ix * iy\n",
|
|
" return inter / (a[2] * a[3] + b[2] * b[3] - inter)\n",
|
|
"\n",
|
|
"# ---- 추적·특징·패턴 공통 함수 ----\n",
|
|
"def center(b):\n",
|
|
" return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])\n",
|
|
"\n",
|
|
"\n",
|
|
"def track(per_frame, gate=6.0, max_missed=2, min_len=5):\n",
|
|
" \"\"\"예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다.\"\"\"\n",
|
|
" active, done = [], []\n",
|
|
" for t, dets in enumerate(per_frame):\n",
|
|
" used = set()\n",
|
|
" for tr in sorted(active, key=lambda x: -len(x[\"t\"])):\n",
|
|
" last = center(tr[\"b\"][-1])\n",
|
|
" vel = last - center(tr[\"b\"][-2]) if len(tr[\"b\"]) > 1 else np.zeros(2)\n",
|
|
" pred = last + vel * (t - tr[\"t\"][-1])\n",
|
|
" cand = [(np.linalg.norm(center(d[\"bbox\"]) - pred), j) for j, d in enumerate(dets) if j not in used]\n",
|
|
" dist, j = min(cand, default=(gate, -1))\n",
|
|
" if j >= 0 and dist < gate:\n",
|
|
" used.add(j)\n",
|
|
" tr[\"t\"].append(t); tr[\"b\"].append(dets[j][\"bbox\"])\n",
|
|
" tr[\"area\"].append(dets[j][\"area\"]); tr[\"heat\"].append(dets[j][\"thermal\"])\n",
|
|
" still = []\n",
|
|
" for tr in active:\n",
|
|
" (still if t - tr[\"t\"][-1] <= max_missed else done).append(tr)\n",
|
|
" active = still\n",
|
|
" for j, d in enumerate(dets):\n",
|
|
" if j not in used:\n",
|
|
" active.append({\"t\": [t], \"b\": [d[\"bbox\"]], \"area\": [d[\"area\"]], \"heat\": [d[\"thermal\"]]})\n",
|
|
" return [tr for tr in done + active if len(tr[\"t\"]) >= min_len]\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATURES = [\"mean_speed\", \"speed_std\", \"turn\", \"straight\", \"vy_accel\", \"area\", \"heat\", \"heat_std\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def features(tr):\n",
|
|
" c = np.array([center(b) for b in tr[\"b\"]]); f = np.array(tr[\"t\"], float)\n",
|
|
" v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]\n",
|
|
" sp = np.linalg.norm(v, axis=1)\n",
|
|
" k = 3 if len(c) > 4 else 1\n",
|
|
" v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]\n",
|
|
" hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2\n",
|
|
" turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]\n",
|
|
" half = len(v) // 2\n",
|
|
" return {\"mean_speed\": sp.mean(), \"speed_std\": sp.std(), \"turn\": turns.mean() if len(turns) else 0.0,\n",
|
|
" \"straight\": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),\n",
|
|
" \"vy_accel\": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,\n",
|
|
" \"area\": np.mean(tr[\"area\"]), \"heat\": np.mean(tr[\"heat\"]), \"heat_std\": np.std(tr[\"heat\"]),\n",
|
|
" \"vx\": v[:, 0].mean(), \"vy\": v[:, 1].mean(), \"x\": c[-1, 0], \"y\": c[-1, 1]}\n",
|
|
"\n",
|
|
"\n",
|
|
"def pattern(f):\n",
|
|
" if f[\"mean_speed\"] < 0.25:\n",
|
|
" return \"static\"\n",
|
|
" if f[\"vy_accel\"] > 0.06:\n",
|
|
" return \"dive\"\n",
|
|
" if f[\"turn\"] > 0.4 or f[\"straight\"] < 0.6:\n",
|
|
" return \"erratic\"\n",
|
|
" return \"steady\"\n",
|
|
"\n",
|
|
"\n",
|
|
"def swarms(trs, vel=0.2, dist=18.0, min_n=3):\n",
|
|
" \"\"\"속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다.\"\"\"\n",
|
|
" cand = [i for i, t in enumerate(trs) if t[\"pattern\"] != \"static\"]\n",
|
|
" adj = {i: set() for i in cand}\n",
|
|
" for a in cand:\n",
|
|
" for b in cand:\n",
|
|
" fa, fb = trs[a][\"f\"], trs[b][\"f\"]\n",
|
|
" if a < b and np.hypot(fa[\"vx\"] - fb[\"vx\"], fa[\"vy\"] - fb[\"vy\"]) < vel and np.hypot(fa[\"x\"] - fb[\"x\"], fa[\"y\"] - fb[\"y\"]) < dist:\n",
|
|
" adj[a].add(b); adj[b].add(a)\n",
|
|
" seen, groups = set(), []\n",
|
|
" for i in cand:\n",
|
|
" if i in seen:\n",
|
|
" continue\n",
|
|
" stack, comp = [i], []\n",
|
|
" while stack:\n",
|
|
" k = stack.pop()\n",
|
|
" if k not in seen:\n",
|
|
" seen.add(k); comp.append(k); stack += list(adj[k] - seen)\n",
|
|
" if len(comp) >= min_n:\n",
|
|
" groups.append(comp)\n",
|
|
" return groups\n",
|
|
"\n",
|
|
"\n",
|
|
"def analyze(s, k=2.0):\n",
|
|
" \"\"\"시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용).\"\"\"\n",
|
|
" per = [detect(fr, s[\"cond\"], k) if fr is not None else [] for fr in s[\"frames\"]]\n",
|
|
" trs = track(per)\n",
|
|
" for tr in trs:\n",
|
|
" tr[\"f\"] = features(tr); tr[\"pattern\"] = pattern(tr[\"f\"]); tr[\"swarm\"] = None; tr[\"seq\"] = s[\"id\"]\n",
|
|
" votes = {}\n",
|
|
" for t, b in zip(tr[\"t\"], tr[\"b\"]):\n",
|
|
" best = max(((iou(b, l[\"bbox\"]), l) for l in s[\"labels\"] if l[\"frame\"] == t), default=(0, None), key=lambda x: x[0])\n",
|
|
" key = (best[1][\"track\"], best[1][\"class\"], best[1][\"pattern\"]) if best[0] > 0.05 else (\"none\", \"noise\", \"normal\")\n",
|
|
" votes[key] = votes.get(key, 0) + 1\n",
|
|
" tr[\"gt_track\"], tr[\"gt_class\"], tr[\"gt_pattern\"] = max(votes, key=votes.get)\n",
|
|
" for g, comp in enumerate(swarms(trs)):\n",
|
|
" for i in comp:\n",
|
|
" trs[i][\"swarm\"] = g\n",
|
|
" if trs[i][\"pattern\"] in (\"steady\", \"erratic\"):\n",
|
|
" trs[i][\"pattern\"] = \"swarm\"\n",
|
|
" return trs\n",
|
|
"\n",
|
|
"\n",
|
|
"data = make_dataset(24, seed=7)\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"tracks = [tr for s in data for tr in analyze(s)]\n",
|
|
"latency = (time.perf_counter() - t0) / sum(len(s[\"frames\"]) for s in data) * 1000\n",
|
|
"\n",
|
|
"# 1) 드론 궤적 재현율: 정답 드론 중 '움직이는 위협 후보'로 잡힌 비율\n",
|
|
"gt_drones = {l[\"track\"] for s in data for l in s[\"labels\"] if l[\"class\"] == \"drone\"}\n",
|
|
"found = {t[\"gt_track\"] for t in tracks if t[\"gt_class\"] == \"drone\" and t[\"pattern\"] != \"static\"}\n",
|
|
"recall = len(found) / len(gt_drones)\n",
|
|
"print(f\"드론 궤적 재현율 {recall:.1%} (목표 85% 이상: {'달성' if recall >= 0.85 else '미달'}), 프레임당 처리 {latency:.2f} ms (목표 200 ms 이하)\")\n",
|
|
"\n",
|
|
"# 2) 실제 정체 x 판정 패턴 교차표\n",
|
|
"print(\"\\n[실제 정체별 판정 패턴]\")\n",
|
|
"print(pd.crosstab(pd.Series([t[\"gt_class\"] for t in tracks], name=\"실제\"), pd.Series([t[\"pattern\"] for t in tracks], name=\"판정\")))\n",
|
|
"\n",
|
|
"# 3) 급강하·군집 시나리오 식별\n",
|
|
"dives = [t for t in tracks if t[\"gt_pattern\"] == \"dive\"]\n",
|
|
"print(f\"\\n급강하 드론 {len(dives)}개 중 dive 판정 {sum(t['pattern'] == 'dive' for t in dives)}개\")\n",
|
|
"for sid in sorted({t[\"seq\"] for t in tracks if t[\"gt_pattern\"] == \"swarm\"}):\n",
|
|
" n = sum(1 for t in tracks if t[\"seq\"] == sid and t[\"swarm\"] is not None and t[\"gt_class\"] == \"drone\")\n",
|
|
" print(f\"군집 시나리오 {sid}: 군집으로 묶인 드론 궤적 {n}개\")\n",
|
|
"print(\"\\n참고: 화면 가장자리에 닿아 속도가 왜곡되면 군집이 끊길 수 있다. 조류처럼 흔들리며 나는 기만 비행은 움직임만으로 구분이 어려워 미션 5에서 열신호 등과 함께 판단한다.\")\n",
|
|
"\n",
|
|
"# 4) 궤적 시각화\n",
|
|
"s = next(x for x in data if x[\"kind\"] == \"swarm\")\n",
|
|
"trs = analyze(s)\n",
|
|
"color = {\"steady\": \"tab:blue\", \"dive\": \"tab:red\", \"erratic\": \"tab:gray\", \"swarm\": \"tab:purple\", \"static\": \"black\"}\n",
|
|
"plt.figure(figsize=(6, 6))\n",
|
|
"plt.imshow(s[\"frames\"][-1], cmap=\"gray\")\n",
|
|
"for t in trs:\n",
|
|
" c = np.array([center(b) for b in t[\"b\"]])\n",
|
|
" plt.plot(c[:, 0], c[:, 1], \"-\", color=color[t[\"pattern\"]], lw=2, label=t[\"pattern\"])\n",
|
|
"h, l = plt.gca().get_legend_handles_labels()\n",
|
|
"plt.legend(dict(zip(l, h)).values(), dict(zip(l, h)).keys())\n",
|
|
"plt.title(f\"{s['id']} tracks by pattern (synthetic)\")\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"드론 궤적 재현율 100.0% (목표 85% 이상: 달성), 프레임당 처리 1.02 ms (목표 200 ms 이하)\n",
|
|
"\n",
|
|
"[실제 정체별 판정 패턴]\n",
|
|
"판정 dive erratic static steady swarm\n",
|
|
"실제 \n",
|
|
"bird 5 12 0 12 0\n",
|
|
"civil 0 5 0 4 0\n",
|
|
"clutter 0 4 11 0 0\n",
|
|
"drone 3 4 0 25 12\n",
|
|
"\n",
|
|
"급강하 드론 3개 중 dive 판정 3개\n",
|
|
"군집 시나리오 SEQ005: 군집으로 묶인 드론 궤적 4개\n",
|
|
"군집 시나리오 SEQ013: 군집으로 묶인 드론 궤적 4개\n",
|
|
"군집 시나리오 SEQ021: 군집으로 묶인 드론 궤적 4개\n",
|
|
"\n",
|
|
"참고: 화면 가장자리에 닿아 속도가 왜곡되면 군집이 끊길 수 있다. 조류처럼 흔들리며 나는 기만 비행은 움직임만으로 구분이 어려워 미션 5에서 열신호 등과 함께 판단한다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 05. 조류·민간기 등 오표적 식별 방지 및 신뢰도 점수(Confidence Score) 산출 엔진 구현\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"조류, 민간 비행체, 레이더 반사체 등 오표적(False Alarm)을 식별하고 비행체 크기·속도·열 신호 기반 다차원 신뢰도 점수(Confidence Score) 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조류·민간 비행체·반사체 등 오표적을 걸러내기 위해 크기·속도·열신호 기반 신뢰도 점수(Confidence Score)와 등급을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **학습용과 검증용 가상 데이터를 분리해 로지스틱 회귀 기반 엔진 학습하여 등급별 신뢰도를 판정하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/verification/ops01_confidence_engine.py` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"조류·민간기·지상 반사체 등 오표적을 걸러내기 위해 궤적의 크기·속도·열신호 특징으로 '드론일 확률'(Confidence Score)과 등급(High/Medium/Low)을 계산하고, 기존 방식 대비 오경보율(FAR) 감축과 점수 일관성을 검증한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.linear_model import LogisticRegression\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)\n",
|
|
"# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"H = W = 64\n",
|
|
"PROFILE = {\"drone\": ((2, 3), (170, 225)), \"bird\": ((2, 3), (110, 150)),\n",
|
|
" \"civil\": ((4, 6), (190, 240)), \"clutter\": ((2, 4), (140, 190))}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _new_obj(cls, rng, dive=False, v=None, xy=None):\n",
|
|
" (s0, s1), (h0, h1) = PROFILE[cls]\n",
|
|
" x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))\n",
|
|
" speed = {\"drone\": 1.3, \"bird\": 0.9, \"civil\": 0.35, \"clutter\": 0.0}[cls]\n",
|
|
" vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)\n",
|
|
" return {\"cls\": cls, \"size\": int(rng.integers(s0, s1 + 1)), \"heat\": float(rng.uniform(h0, h1)),\n",
|
|
" \"x\": float(x), \"y\": float(y), \"vx\": float(vx), \"vy\": float(vy), \"dive\": dive}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _noise(img, cond, rng):\n",
|
|
" f = img.astype(float)\n",
|
|
" if cond == \"night\":\n",
|
|
" f = f * 0.8 + rng.normal(0, 14, f.shape)\n",
|
|
" elif cond == \"fog\":\n",
|
|
" f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)\n",
|
|
" elif cond == \"rain\":\n",
|
|
" f = f + rng.normal(0, 8, f.shape)\n",
|
|
" for _ in range(20):\n",
|
|
" x, y0 = rng.integers(0, W), rng.integers(0, H - 8)\n",
|
|
" f[y0:y0 + 8, x] += 45\n",
|
|
" else:\n",
|
|
" f = f + rng.normal(0, 6, f.shape)\n",
|
|
" return np.clip(f, 0, 255).astype(np.uint8)\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_sequence(seq_id, cond, rng, kind=\"mixed\", n_frames=30, n_drone=None, n_bird=None):\n",
|
|
" \"\"\"시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation\"\"\"\n",
|
|
" objs = []\n",
|
|
" if kind == \"swarm\":\n",
|
|
" v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)\n",
|
|
" objs = [_new_obj(\"drone\", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]\n",
|
|
" objs.append(_new_obj(\"bird\", rng))\n",
|
|
" elif kind == \"dive\":\n",
|
|
" d = _new_obj(\"drone\", rng, dive=True)\n",
|
|
" d[\"y\"] = float(rng.uniform(4, 14))\n",
|
|
" objs = [d, _new_obj(\"bird\", rng), _new_obj(\"clutter\", rng)]\n",
|
|
" elif kind == \"saturation\":\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(n_drone)] + [_new_obj(\"bird\", rng) for _ in range(n_bird)]\n",
|
|
" else:\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(int(rng.integers(1, 3)))]\n",
|
|
" objs += [_new_obj(\"bird\", rng) for _ in range(int(rng.integers(0, 3)))]\n",
|
|
" if rng.random() < 0.5:\n",
|
|
" objs.append(_new_obj(\"civil\", rng))\n",
|
|
" if rng.random() < 0.6:\n",
|
|
" objs.append(_new_obj(\"clutter\", rng))\n",
|
|
" frames, labels = np.zeros((n_frames, H, W), np.uint8), []\n",
|
|
" base = rng.normal(40, 6, (H, W))\n",
|
|
" for t in range(n_frames):\n",
|
|
" canvas = base.copy()\n",
|
|
" for i, o in enumerate(objs):\n",
|
|
" if t > 0:\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" o[\"vx\"] = float(np.clip(o[\"vx\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" o[\"vy\"] = float(np.clip(o[\"vy\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" if o[\"dive\"] and t > 10:\n",
|
|
" o[\"vy\"] += 0.18\n",
|
|
" o[\"x\"] = float(np.clip(o[\"x\"] + o[\"vx\"], 1, W - o[\"size\"] - 1))\n",
|
|
" o[\"y\"] = float(np.clip(o[\"y\"] + o[\"vy\"], 1, H - o[\"size\"] - 1))\n",
|
|
" heat = o[\"heat\"]\n",
|
|
" if o[\"cls\"] == \"clutter\" and rng.random() < 0.3:\n",
|
|
" heat *= 0.7\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" heat *= float(rng.uniform(0.85, 1.1))\n",
|
|
" x, y, s = int(round(o[\"x\"])), int(round(o[\"y\"])), o[\"size\"]\n",
|
|
" canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)\n",
|
|
" labels.append({\"frame\": t, \"track\": f\"{seq_id}_T{i:02d}\", \"class\": o[\"cls\"], \"bbox\": [x, y, s, s],\n",
|
|
" \"pattern\": \"dive\" if o[\"dive\"] else (\"swarm\" if kind == \"swarm\" and o[\"cls\"] == \"drone\" else \"normal\")})\n",
|
|
" frames[t] = _noise(canvas, cond, rng)\n",
|
|
" return frames, labels\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=24, seed=7):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" conds = [\"clear\", \"night\", \"fog\", \"rain\"]\n",
|
|
" out = []\n",
|
|
" for i in range(n):\n",
|
|
" kind = \"swarm\" if i % 8 == 5 else (\"dive\" if i % 8 == 6 else \"mixed\")\n",
|
|
" f, lab = make_sequence(f\"SEQ{i:03d}\", conds[i % 4], rng, kind)\n",
|
|
" out.append({\"id\": f\"SEQ{i:03d}\", \"cond\": conds[i % 4], \"kind\": kind, \"frames\": f, \"labels\": lab})\n",
|
|
" return out\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리·탐지 공통 함수 ----\n",
|
|
"def preprocess(frame, cond):\n",
|
|
" f = frame.astype(float)\n",
|
|
" if cond == \"rain\": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)\n",
|
|
" f = ndimage.grey_opening(f, size=(1, 2))\n",
|
|
" lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장\n",
|
|
" return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n",
|
|
"\n",
|
|
"\n",
|
|
"def detect(frame, cond, k=2.0, min_area=3, max_area=60):\n",
|
|
" img = preprocess(frame, cond)\n",
|
|
" lab, _ = ndimage.label(img > img.mean() + k * img.std())\n",
|
|
" dets = []\n",
|
|
" for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n",
|
|
" area = int((lab[sl] == i).sum())\n",
|
|
" if min_area <= area <= max_area:\n",
|
|
" y, x = sl[0].start, sl[1].start\n",
|
|
" dets.append({\"bbox\": [x, y, sl[1].stop - x, sl[0].stop - y], \"area\": area,\n",
|
|
" \"thermal\": float(frame[sl][lab[sl] == i].max())})\n",
|
|
" return dets\n",
|
|
"\n",
|
|
"\n",
|
|
"def iou(a, b):\n",
|
|
" ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))\n",
|
|
" iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))\n",
|
|
" inter = ix * iy\n",
|
|
" return inter / (a[2] * a[3] + b[2] * b[3] - inter)\n",
|
|
"\n",
|
|
"# ---- 추적·특징·패턴 공통 함수 ----\n",
|
|
"def center(b):\n",
|
|
" return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])\n",
|
|
"\n",
|
|
"\n",
|
|
"def track(per_frame, gate=6.0, max_missed=2, min_len=5):\n",
|
|
" \"\"\"예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다.\"\"\"\n",
|
|
" active, done = [], []\n",
|
|
" for t, dets in enumerate(per_frame):\n",
|
|
" used = set()\n",
|
|
" for tr in sorted(active, key=lambda x: -len(x[\"t\"])):\n",
|
|
" last = center(tr[\"b\"][-1])\n",
|
|
" vel = last - center(tr[\"b\"][-2]) if len(tr[\"b\"]) > 1 else np.zeros(2)\n",
|
|
" pred = last + vel * (t - tr[\"t\"][-1])\n",
|
|
" cand = [(np.linalg.norm(center(d[\"bbox\"]) - pred), j) for j, d in enumerate(dets) if j not in used]\n",
|
|
" dist, j = min(cand, default=(gate, -1))\n",
|
|
" if j >= 0 and dist < gate:\n",
|
|
" used.add(j)\n",
|
|
" tr[\"t\"].append(t); tr[\"b\"].append(dets[j][\"bbox\"])\n",
|
|
" tr[\"area\"].append(dets[j][\"area\"]); tr[\"heat\"].append(dets[j][\"thermal\"])\n",
|
|
" still = []\n",
|
|
" for tr in active:\n",
|
|
" (still if t - tr[\"t\"][-1] <= max_missed else done).append(tr)\n",
|
|
" active = still\n",
|
|
" for j, d in enumerate(dets):\n",
|
|
" if j not in used:\n",
|
|
" active.append({\"t\": [t], \"b\": [d[\"bbox\"]], \"area\": [d[\"area\"]], \"heat\": [d[\"thermal\"]]})\n",
|
|
" return [tr for tr in done + active if len(tr[\"t\"]) >= min_len]\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATURES = [\"mean_speed\", \"speed_std\", \"turn\", \"straight\", \"vy_accel\", \"area\", \"heat\", \"heat_std\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def features(tr):\n",
|
|
" c = np.array([center(b) for b in tr[\"b\"]]); f = np.array(tr[\"t\"], float)\n",
|
|
" v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]\n",
|
|
" sp = np.linalg.norm(v, axis=1)\n",
|
|
" k = 3 if len(c) > 4 else 1\n",
|
|
" v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]\n",
|
|
" hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2\n",
|
|
" turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]\n",
|
|
" half = len(v) // 2\n",
|
|
" return {\"mean_speed\": sp.mean(), \"speed_std\": sp.std(), \"turn\": turns.mean() if len(turns) else 0.0,\n",
|
|
" \"straight\": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),\n",
|
|
" \"vy_accel\": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,\n",
|
|
" \"area\": np.mean(tr[\"area\"]), \"heat\": np.mean(tr[\"heat\"]), \"heat_std\": np.std(tr[\"heat\"]),\n",
|
|
" \"vx\": v[:, 0].mean(), \"vy\": v[:, 1].mean(), \"x\": c[-1, 0], \"y\": c[-1, 1]}\n",
|
|
"\n",
|
|
"\n",
|
|
"def pattern(f):\n",
|
|
" if f[\"mean_speed\"] < 0.25:\n",
|
|
" return \"static\"\n",
|
|
" if f[\"vy_accel\"] > 0.06:\n",
|
|
" return \"dive\"\n",
|
|
" if f[\"turn\"] > 0.4 or f[\"straight\"] < 0.6:\n",
|
|
" return \"erratic\"\n",
|
|
" return \"steady\"\n",
|
|
"\n",
|
|
"\n",
|
|
"def swarms(trs, vel=0.2, dist=18.0, min_n=3):\n",
|
|
" \"\"\"속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다.\"\"\"\n",
|
|
" cand = [i for i, t in enumerate(trs) if t[\"pattern\"] != \"static\"]\n",
|
|
" adj = {i: set() for i in cand}\n",
|
|
" for a in cand:\n",
|
|
" for b in cand:\n",
|
|
" fa, fb = trs[a][\"f\"], trs[b][\"f\"]\n",
|
|
" if a < b and np.hypot(fa[\"vx\"] - fb[\"vx\"], fa[\"vy\"] - fb[\"vy\"]) < vel and np.hypot(fa[\"x\"] - fb[\"x\"], fa[\"y\"] - fb[\"y\"]) < dist:\n",
|
|
" adj[a].add(b); adj[b].add(a)\n",
|
|
" seen, groups = set(), []\n",
|
|
" for i in cand:\n",
|
|
" if i in seen:\n",
|
|
" continue\n",
|
|
" stack, comp = [i], []\n",
|
|
" while stack:\n",
|
|
" k = stack.pop()\n",
|
|
" if k not in seen:\n",
|
|
" seen.add(k); comp.append(k); stack += list(adj[k] - seen)\n",
|
|
" if len(comp) >= min_n:\n",
|
|
" groups.append(comp)\n",
|
|
" return groups\n",
|
|
"\n",
|
|
"\n",
|
|
"def analyze(s, k=2.0):\n",
|
|
" \"\"\"시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용).\"\"\"\n",
|
|
" per = [detect(fr, s[\"cond\"], k) if fr is not None else [] for fr in s[\"frames\"]]\n",
|
|
" trs = track(per)\n",
|
|
" for tr in trs:\n",
|
|
" tr[\"f\"] = features(tr); tr[\"pattern\"] = pattern(tr[\"f\"]); tr[\"swarm\"] = None; tr[\"seq\"] = s[\"id\"]\n",
|
|
" votes = {}\n",
|
|
" for t, b in zip(tr[\"t\"], tr[\"b\"]):\n",
|
|
" best = max(((iou(b, l[\"bbox\"]), l) for l in s[\"labels\"] if l[\"frame\"] == t), default=(0, None), key=lambda x: x[0])\n",
|
|
" key = (best[1][\"track\"], best[1][\"class\"], best[1][\"pattern\"]) if best[0] > 0.05 else (\"none\", \"noise\", \"normal\")\n",
|
|
" votes[key] = votes.get(key, 0) + 1\n",
|
|
" tr[\"gt_track\"], tr[\"gt_class\"], tr[\"gt_pattern\"] = max(votes, key=votes.get)\n",
|
|
" for g, comp in enumerate(swarms(trs)):\n",
|
|
" for i in comp:\n",
|
|
" trs[i][\"swarm\"] = g\n",
|
|
" if trs[i][\"pattern\"] in (\"steady\", \"erratic\"):\n",
|
|
" trs[i][\"pattern\"] = \"swarm\"\n",
|
|
" return trs\n",
|
|
"\n",
|
|
"\n",
|
|
"def X_of(trs):\n",
|
|
" return np.array([[t[\"f\"][k] for k in FEATURES] for t in trs])\n",
|
|
"\n",
|
|
"\n",
|
|
"def grade(p):\n",
|
|
" return \"High\" if p >= 0.8 else (\"Medium\" if p >= 0.5 else \"Low\")\n",
|
|
"\n",
|
|
"\n",
|
|
"def stats(trs, alert_key):\n",
|
|
" alerts = [t for t in trs if alert_key(t)]\n",
|
|
" true = [t for t in alerts if t[\"gt_class\"] == \"drone\"]\n",
|
|
" drones = {t[\"gt_track\"] for t in trs if t[\"gt_class\"] == \"drone\"}\n",
|
|
" far = (len(alerts) - len(true)) / max(len(alerts), 1)\n",
|
|
" return {\"경보 수\": len(alerts), \"드론 경보\": len(true), \"오경보율\": round(far, 3),\n",
|
|
" \"드론 재현율\": round(len({t[\"gt_track\"] for t in true}) / max(len(drones), 1), 3)}\n",
|
|
"\n",
|
|
"\n",
|
|
"train = [t for s in make_dataset(24, seed=7) for t in analyze(s)] # 학습용\n",
|
|
"test = [t for s in make_dataset(24, seed=11) for t in analyze(s)] # 검증용(학습에 쓰지 않음)\n",
|
|
"\n",
|
|
"model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=1000, random_state=0))\n",
|
|
"model.fit(X_of(train), [1 if t[\"gt_class\"] == \"drone\" else 0 for t in train])\n",
|
|
"for t, p in zip(test, model.predict_proba(X_of(test))[:, 1]):\n",
|
|
" t[\"score\"] = 0.0 if t[\"pattern\"] == \"static\" else float(p)\n",
|
|
" t[\"grade\"] = grade(t[\"score\"])\n",
|
|
"\n",
|
|
"before = stats(test, lambda t: t[\"pattern\"] != \"static\") # 기존: 움직이면 모두 경보\n",
|
|
"after = stats(test, lambda t: t[\"pattern\"] != \"static\" and t[\"score\"] >= 0.5)\n",
|
|
"print(pd.DataFrame([{\"방식\": \"기존(움직이면 경보)\", **before}, {\"방식\": \"신뢰도 엔진(0.5 이상)\", **after}]).to_string(index=False))\n",
|
|
"cut = 1 - after[\"오경보율\"] / max(before[\"오경보율\"], 1e-9)\n",
|
|
"print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n",
|
|
"\n",
|
|
"# 점수 일관성: 같은 입력을 다시 넣었을 때 같은 등급이 나오는 비율\n",
|
|
"again = [grade(0.0 if t[\"pattern\"] == \"static\" else p) for t, p in zip(test, model.predict_proba(X_of(test))[:, 1])]\n",
|
|
"print(f\"신뢰도 점수 산출 일관성 {np.mean([a == t['grade'] for a, t in zip(again, test)]):.0%}\")\n",
|
|
"\n",
|
|
"print(\"\\n[실제 정체별 등급 분포]\")\n",
|
|
"print(pd.crosstab(pd.Series([t[\"gt_class\"] for t in test], name=\"실제\"), pd.Series([t[\"grade\"] for t in test], name=\"등급\")))\n",
|
|
"\n",
|
|
"# 판단 근거: 점수에 가장 크게 기여한 특징\n",
|
|
"scaler, lr = model[0], model[-1]\n",
|
|
"top = sorted(test, key=lambda t: -t[\"score\"])[0]\n",
|
|
"contrib = (np.array([top[\"f\"][k] for k in FEATURES]) - scaler.mean_) / scaler.scale_ * lr.coef_[0]\n",
|
|
"print(f\"\\n예시 경보 {top['seq']} (점수 {top['score']:.2f}) 판단 근거:\",\n",
|
|
" \", \".join(f\"{FEATURES[i]}({contrib[i]:+.2f})\" for i in np.argsort(-np.abs(contrib))[:3]))\n",
|
|
"print(\"점수는 경보 확정이 아니라 운용자가 먼저 볼 순서를 정하는 용도다.\")\n",
|
|
"\n",
|
|
"plt.figure(figsize=(7, 4))\n",
|
|
"for cls, col in [(\"drone\", \"tab:red\"), (\"bird\", \"tab:blue\"), (\"civil\", \"tab:green\"), (\"clutter\", \"gray\")]:\n",
|
|
" plt.hist([t[\"score\"] for t in test if t[\"gt_class\"] == cls], bins=10, range=(0, 1), alpha=0.6, label=cls, color=col)\n",
|
|
"plt.axvline(0.5, color=\"k\", ls=\"--\"); plt.xlabel(\"confidence score\"); plt.ylabel(\"tracks\"); plt.legend()\n",
|
|
"plt.title(\"Confidence score by true class (synthetic)\")\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
" 방식 경보 수 드론 경보 오경보율 드론 재현율\n",
|
|
" 기존(움직이면 경보) 80 44 0.450 1.000\n",
|
|
"신뢰도 엔진(0.5 이상) 45 43 0.044 0.977\n",
|
|
"\n",
|
|
"오경보율 감축 90.2% (목표 40% 이상: 달성)\n",
|
|
"신뢰도 점수 산출 일관성 100%\n",
|
|
"\n",
|
|
"[실제 정체별 등급 분포]\n",
|
|
"등급 High Low Medium\n",
|
|
"실제 \n",
|
|
"bird 0 23 2\n",
|
|
"civil 0 9 0\n",
|
|
"clutter 0 15 0\n",
|
|
"drone 37 1 6\n",
|
|
"\n",
|
|
"예시 경보 SEQ000 (점수 1.00) 판단 근거: heat(+2.97), area(+1.43), straight(+1.14)\n",
|
|
"점수는 경보 확정이 아니라 운용자가 먼저 볼 순서를 정하는 용도다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 06. 실시간 공중위협 경보 및 방공 사격구역 가시화 전술 대시보드 구현\n",
|
|
"**소요시간**: 약 2.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"지휘관 및 방공 통제관이 실시간으로 위협 표적의 방위, 고도, 속도, 예상 탄착점을 파악할 수 있는 전술 공중위협 상황판(COP) 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관과 방공 통제관이 위협 표적의 방위·고도·속도·예상 탄착점을 한눈에 파악하는 전술 공중위협 상황판(COP)을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **방어 지점 기준 가상 표적의 방위·거리·고도·속도·도달 예상시간을 계산하고, 하강 궤적을 외삽해 예상 탄착점을 구 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/ui/ops01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"지휘관·방공 통제관용 공중위협 상황판(COP)을 구현한다. 가상 표적의 방위·고도·속도와 궤적 외삽으로 구한 예상 탄착점을 방어 지점 기준 방공 사격구역(가상 원형 구역)과 함께 표시하고, 긴급 경보를 강조한다. 화면 갱신 처리시간도 측정한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"\n",
|
|
"# [교육용가상자료] 방어 지점(0,0) 기준 가상 표적 12개: 위치(km), 고도(m), 속도 벡터(m/s)\n",
|
|
"rng = np.random.default_rng(3)\n",
|
|
"N = 12\n",
|
|
"kinds = rng.choice([\"recon_drone\", \"attack_drone\", \"cruise_missile\", \"bird\"], N, p=[0.35, 0.35, 0.15, 0.15])\n",
|
|
"pos = rng.uniform(-12, 12, (N, 2))\n",
|
|
"alt = np.where(kinds == \"cruise_missile\", rng.uniform(50, 150, N), rng.uniform(100, 1500, N))\n",
|
|
"spd = np.select([kinds == \"cruise_missile\", kinds == \"bird\"], [rng.uniform(200, 250, N), rng.uniform(8, 15, N)], rng.uniform(25, 50, N))\n",
|
|
"to_center = -pos / np.linalg.norm(pos, axis=1, keepdims=True)\n",
|
|
"heading = to_center + rng.normal(0, 0.35, (N, 2))\n",
|
|
"heading /= np.linalg.norm(heading, axis=1, keepdims=True)\n",
|
|
"vel = heading * spd[:, None] # 수평 속도(m/s)\n",
|
|
"vz = np.where(kinds == \"attack_drone\", -rng.uniform(3, 8, N), np.where(kinds == \"bird\", 0, -rng.uniform(0, 1, N)))\n",
|
|
"score = np.select([kinds == \"bird\"], [rng.uniform(0.05, 0.4, N)], rng.uniform(0.55, 0.98, N)) # 미션 5 신뢰도 점수 가정\n",
|
|
"\n",
|
|
"FIRE_ZONE_KM = 5.0 # 가상 방공 사격구역 반경\n",
|
|
"\n",
|
|
"\n",
|
|
"def build_picture(pos, alt, vel, vz):\n",
|
|
" rows = []\n",
|
|
" for i in range(N):\n",
|
|
" az = (np.degrees(np.arctan2(pos[i, 0], pos[i, 1])) + 360) % 360\n",
|
|
" rng_km = float(np.linalg.norm(pos[i]))\n",
|
|
" closing = -float(np.dot(pos[i], vel[i]) / max(rng_km, 1e-6)) / 1000 # km/s 접근 속도\n",
|
|
" tti = rng_km / closing if closing > 0 else np.inf\n",
|
|
" if vz[i] < -2: # 하강 중이면 고도 0 도달 지점(예상 탄착점) 외삽\n",
|
|
" t_hit = alt[i] / -vz[i]\n",
|
|
" impact = pos[i] + vel[i] * t_hit / 1000\n",
|
|
" else:\n",
|
|
" impact = np.array([np.nan, np.nan])\n",
|
|
" in_zone = rng_km <= FIRE_ZONE_KM\n",
|
|
" rows.append({\"ID\": f\"T{i:02d}\", \"유형(추정)\": kinds[i], \"방위(도)\": round(az), \"거리(km)\": round(rng_km, 1),\n",
|
|
" \"고도(m)\": int(alt[i]), \"속도(m/s)\": int(np.linalg.norm(vel[i])), \"도달예상(s)\": None if np.isinf(tti) else int(tti),\n",
|
|
" \"예상탄착점(km)\": None if np.isnan(impact[0]) else f\"({impact[0]:.1f}, {impact[1]:.1f})\",\n",
|
|
" \"사격구역 내\": in_zone, \"신뢰도\": round(float(score[i]), 2),\n",
|
|
" \"긴급\": bool(score[i] >= 0.5 and (in_zone or (not np.isinf(tti) and tti < 120)))})\n",
|
|
" return pd.DataFrame(rows), [None if np.isnan(r[0]) else r for r in\n",
|
|
" [pos[i] + vel[i] * (alt[i] / -vz[i]) / 1000 if vz[i] < -2 else np.array([np.nan, np.nan]) for i in range(N)]]\n",
|
|
"\n",
|
|
"\n",
|
|
"# 1) 1초 주기 갱신 모의: 5회 갱신하며 처리시간 측정\n",
|
|
"times = []\n",
|
|
"for step in range(5):\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" table, impacts = build_picture(pos, alt, vel, vz)\n",
|
|
" times.append((time.perf_counter() - t0) * 1000)\n",
|
|
" pos = pos + vel / 1000 * 1.0 # 1초 경과\n",
|
|
" alt = np.maximum(alt + vz, 0)\n",
|
|
"print(f\"상황판 갱신 처리시간 평균 {np.mean(times):.1f} ms (목표: 갱신 주기 1초 이내, 응답 100 ms 이내)\")\n",
|
|
"print(\"\\n[공중위협 상황 목록: 긴급 경보 우선]\")\n",
|
|
"print(table.sort_values([\"긴급\", \"신뢰도\"], ascending=False).fillna(\"-\").to_string(index=False))\n",
|
|
"\n",
|
|
"# 2) 전술 상황판 그림 (야간 운용을 고려한 어두운 배경, 긴급 경보는 굵게 강조)\n",
|
|
"plt.style.use(\"dark_background\")\n",
|
|
"fig, ax = plt.subplots(1, 2, figsize=(13, 6), gridspec_kw={\"width_ratios\": [1.2, 1]})\n",
|
|
"a = ax[0]\n",
|
|
"a.add_patch(plt.Circle((0, 0), FIRE_ZONE_KM, color=\"orange\", alpha=0.15, label=\"air-defense zone (virtual)\"))\n",
|
|
"a.plot(0, 0, \"w^\", ms=10, label=\"defended point\")\n",
|
|
"for i, r in table.iterrows():\n",
|
|
" c = \"red\" if r[\"긴급\"] else (\"gray\" if kinds[i] == \"bird\" else \"deepskyblue\")\n",
|
|
" a.arrow(pos[i, 0], pos[i, 1], vel[i, 0] / 25, vel[i, 1] / 25, color=c, width=0.08, head_width=0.4)\n",
|
|
" a.text(pos[i, 0] + 0.3, pos[i, 1] + 0.3, f\"{r['ID']} {r['고도(m)']}m\", color=c, fontsize=8,\n",
|
|
" fontweight=\"bold\" if r[\"긴급\"] else \"normal\")\n",
|
|
" if impacts[i] is not None:\n",
|
|
" a.plot(*impacts[i], \"x\", color=c, ms=9)\n",
|
|
"a.set_xlim(-15, 15); a.set_ylim(-15, 15); a.set_aspect(\"equal\"); a.grid(alpha=0.2)\n",
|
|
"a.set_title(\"Air threat picture (x = predicted impact point)\"); a.set_xlabel(\"East (km)\"); a.set_ylabel(\"North (km)\")\n",
|
|
"a.legend(loc=\"lower left\", fontsize=8)\n",
|
|
"b = ax[1]\n",
|
|
"counts = table.groupby(\"유형(추정)\")[\"ID\"].count()\n",
|
|
"b.barh(counts.index, counts.values, color=\"deepskyblue\"); b.set_title(f\"Threats by type (urgent: {int(table['긴급'].sum())})\")\n",
|
|
"plt.suptitle(\"Tactical COP - training data only\")\n",
|
|
"plt.tight_layout(); plt.show()\n",
|
|
"print(\"\\n표시된 경보·탄착점은 판단 보조 정보이며, 교전 여부는 미션 7의 인간 승인 절차와 실제 교전수칙·지휘계통에 따른다.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"상황판 갱신 처리시간 평균 1.3 ms (목표: 갱신 주기 1초 이내, 응답 100 ms 이내)\n",
|
|
"\n",
|
|
"[공중위협 상황 목록: 긴급 경보 우선]\n",
|
|
" ID 유형(추정) 방위(도) 거리(km) 고도(m) 속도(m/s) 도달예상(s) 예상탄착점(km) 사격구역 내 신뢰도 긴급\n",
|
|
"T00 recon_drone 321 2.5 1287 49 50 - True 0.98 True\n",
|
|
"T08 cruise_missile 296 11.4 91 248 53 - False 0.95 True\n",
|
|
"T02 cruise_missile 308 5.4 120 237 25 - False 0.78 True\n",
|
|
"T03 attack_drone 137 6.7 276 38 173 (3.5, -3.8) False 0.93 False\n",
|
|
"T07 recon_drone 354 6.5 483 34 193 - False 0.88 False\n",
|
|
"T05 attack_drone 227 6.5 491 26 265 (-1.9, -3.3) False 0.80 False\n",
|
|
"T09 recon_drone 196 10.2 656 41 450 - False 0.78 False\n",
|
|
"T06 attack_drone 78 9.5 1297 41 225 (-0.5, -0.6) False 0.62 False\n",
|
|
"T01 recon_drone 27 12.2 651 42 310 - False 0.61 False\n",
|
|
"T11 attack_drone 293 7.6 350 38 217 (-5.4, 1.4) False 0.58 False\n",
|
|
"T04 recon_drone 314 16.3 1074 42 382 - False 0.57 False\n",
|
|
"T10 attack_drone 20 10.9 939 41 260 (0.6, 2.3) False 0.55 False\n",
|
|
"\n",
|
|
"표시된 경보·탄착점은 판단 보조 정보이며, 교전 여부는 미션 7의 인간 승인 절차와 실제 교전수칙·지휘계통에 따른다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 07. 긴급 요격 결심 지원을 위한 인간 개입(HITL) 교전 승인/보류 워크플로우 설계\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"자동 탐지된 표적에 대해 오인 사격을 방지하고 긴급 요격 명령을 최종 인가할 수 있는 방공작전관 인간 개입(HITL) 교전 승인/보류 워크플로우를 연계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **자동 탐지 표적의 오인 사격을 막기 위해 방공작전관이 직접 승인·보류·반려하는 인간 개입(HITL) 교전 승인/보류 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **AI가 넘긴 표적은 점수와 무관하게 검토 대기로 등록하고 등급·점수 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/hitl/ops01_approval_workflow.py 및 docs/ops01_hitl_guide.md` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"자동 탐지 표적을 방공작전관이 직접 승인·보류·반려하는 인간 개입(HITL) 교전 승인/보류 워크플로우를 구현한다. AI는 자동 승인하지 않으며, 모든 결정은 운용자 ID·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다.\"\"\"\n",
|
|
"import hashlib\n",
|
|
"import json\n",
|
|
"import time\n",
|
|
"import pandas as pd\n",
|
|
"\n",
|
|
"ACTIONS = {\"APPROVE\": \"승인(상급 지휘계통으로 인가 요청)\", \"HOLD\": \"보류(추가 확인)\", \"REJECT\": \"반려(오표적·민간기 등)\"}\n",
|
|
"PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n",
|
|
"HOLD_LIMIT_S = 60\n",
|
|
"\n",
|
|
"\n",
|
|
"class WorkflowError(ValueError):\n",
|
|
" pass\n",
|
|
"\n",
|
|
"\n",
|
|
"class ApprovalQueue:\n",
|
|
" def __init__(self, clock=time.time):\n",
|
|
" self.items, self.log, self.clock = {}, [], clock\n",
|
|
"\n",
|
|
" # ---- 감사 로그: 직전 해시를 포함한 해시 체인(추가 전용) ----\n",
|
|
" def _record(self, body):\n",
|
|
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
|
|
" body = {**body, \"ts\": round(self.clock(), 3)}\n",
|
|
" h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n",
|
|
" self.log.append({**body, \"prev\": prev, \"hash\": h})\n",
|
|
"\n",
|
|
" def verify_log(self):\n",
|
|
" prev = \"GENESIS\"\n",
|
|
" for n, rec in enumerate(self.log, start=1):\n",
|
|
" body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n",
|
|
" if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n",
|
|
" return False, n\n",
|
|
" prev = rec[\"hash\"]\n",
|
|
" return True, -1\n",
|
|
"\n",
|
|
" # ---- 대기열 ----\n",
|
|
" def submit(self, target_id, score, grade):\n",
|
|
" \"\"\"AI 탐지 표적 등록. 점수와 무관하게 항상 PENDING(검토 대기)으로 시작한다.\"\"\"\n",
|
|
" self.items[target_id] = {\"id\": target_id, \"score\": score, \"grade\": grade, \"status\": \"PENDING\",\n",
|
|
" \"submitted\": self.clock(), \"hold_since\": None, \"escalated\": False}\n",
|
|
" self._record({\"event\": \"SUBMIT\", \"id\": target_id, \"score\": score, \"by\": \"AI\"})\n",
|
|
"\n",
|
|
" def pending(self):\n",
|
|
" todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\")]\n",
|
|
" return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"score\"]))\n",
|
|
"\n",
|
|
" def decide(self, target_id, action, operator, reason):\n",
|
|
" if action not in ACTIONS:\n",
|
|
" raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n",
|
|
" if not operator:\n",
|
|
" raise WorkflowError(\"운용자 ID 필요\")\n",
|
|
" item = self.items.get(target_id)\n",
|
|
" if item is None:\n",
|
|
" raise WorkflowError(\"대기열에 없는 표적\")\n",
|
|
" if item[\"status\"] in (\"APPROVED\", \"REJECTED\"):\n",
|
|
" raise WorkflowError(\"이미 종결된 표적\")\n",
|
|
" if len(reason.strip()) < 5:\n",
|
|
" raise WorkflowError(\"판단 근거 5자 이상 필요\")\n",
|
|
" if action == \"APPROVE\" and item[\"grade\"] == \"Low\" and len(reason.strip()) < 15:\n",
|
|
" raise WorkflowError(\"Low 등급 승인은 근거 15자 이상 필요(오인 방지)\")\n",
|
|
" item[\"status\"] = {\"APPROVE\": \"APPROVED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n",
|
|
" item[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n",
|
|
" item[\"decided\"] = self.clock()\n",
|
|
" self._record({\"event\": \"DECIDE\", \"id\": target_id, \"action\": action, \"operator\": operator, \"reason\": reason})\n",
|
|
"\n",
|
|
" def check_holds(self):\n",
|
|
" \"\"\"보류 기한 초과 시 자동 승인하지 않고 상급 검토자에게 상신한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for i in self.items.values():\n",
|
|
" if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n",
|
|
" i[\"escalated\"] = True; out.append(i[\"id\"])\n",
|
|
" self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\"})\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"class FakeClock:\n",
|
|
" t = 1000.0\n",
|
|
"\n",
|
|
" def __call__(self):\n",
|
|
" return self.t\n",
|
|
"\n",
|
|
"\n",
|
|
"clock = FakeClock()\n",
|
|
"q = ApprovalQueue(clock)\n",
|
|
"# [교육용가상자료] 미션 5에서 넘어온 표적 5개\n",
|
|
"for tid, s, g in [(\"T01\", 0.97, \"High\"), (\"T02\", 0.62, \"Medium\"), (\"T03\", 0.44, \"Low\"), (\"T04\", 0.91, \"High\"), (\"T05\", 0.55, \"Medium\")]:\n",
|
|
" q.submit(tid, s, g)\n",
|
|
"print(\"검토 순서:\", [i[\"id\"] for i in q.pending()])\n",
|
|
"print(\"자동 승인된 표적 수:\", sum(i[\"status\"] == \"APPROVED\" for i in q.items.values()))\n",
|
|
"\n",
|
|
"# 규칙 위반 시도들이 모두 차단되는지 확인\n",
|
|
"for args in [(\"T01\", \"APPROVE\", \"\", \"근거 충분함\"), (\"T03\", \"APPROVE\", \"op01\", \"직진 궤적\"), (\"T01\", \"FIRE\", \"op01\", \"허용되지 않은 조치\")]:\n",
|
|
" try:\n",
|
|
" q.decide(*args)\n",
|
|
" except WorkflowError as e:\n",
|
|
" print(\"차단:\", e)\n",
|
|
"\n",
|
|
"clock.t += 3; q.decide(\"T01\", \"APPROVE\", \"op01\", \"열신호 높고 직진, 방어지점 접근 확인\")\n",
|
|
"clock.t += 5; q.decide(\"T04\", \"HOLD\", \"op01\", \"민간 항공 운항정보 확인 필요\")\n",
|
|
"clock.t += 4; q.decide(\"T03\", \"REJECT\", \"op02\", \"날갯짓 열신호 변동, 조류 판정\")\n",
|
|
"clock.t += 2; q.decide(\"T02\", \"HOLD\", \"op02\", \"추가 영상 확인\")\n",
|
|
"clock.t += 70\n",
|
|
"print(\"보류 기한 초과 상신:\", q.check_holds())\n",
|
|
"print(\"상신 후 검토 순서:\", [i[\"id\"] for i in q.pending()])\n",
|
|
"\n",
|
|
"# 처리 결과 요약\n",
|
|
"df = pd.DataFrame(q.items.values())\n",
|
|
"df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(1) if \"decided\" in df else None\n",
|
|
"print(\"\\n\" + df[[\"id\", \"grade\", \"score\", \"status\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n",
|
|
"print(f\"\\n감사 로그 {len(q.log)}건 기록, 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {q.verify_log()}\")\n",
|
|
"q.log[4][\"operator\"] = \"op99\" # 위·변조 시도: 5번째 기록의 운용자 ID 변경\n",
|
|
"print(\"5번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n",
|
|
"print(\"\\n본 워크플로우의 '승인'은 상급 지휘계통으로 인가를 요청하는 단계까지이며, 실제 교전은 교전수칙·지휘계통에 따른다.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"검토 순서: ['T01', 'T04', 'T02', 'T05', 'T03']\n",
|
|
"자동 승인된 표적 수: 0\n",
|
|
"차단: 운용자 ID 필요\n",
|
|
"차단: Low 등급 승인은 근거 15자 이상 필요(오인 방지)\n",
|
|
"차단: 허용되지 않은 조치: FIRE\n",
|
|
"보류 기한 초과 상신: ['T02', 'T04']\n",
|
|
"상신 후 검토 순서: ['T04', 'T02', 'T05']\n",
|
|
"\n",
|
|
" id grade score status escalated 대기시간(s)\n",
|
|
"T01 High 0.97 APPROVED False 3.0\n",
|
|
"T02 Medium 0.62 HOLD True 14.0\n",
|
|
"T03 Low 0.44 REJECTED False 12.0\n",
|
|
"T04 High 0.91 HOLD True 8.0\n",
|
|
"T05 Medium 0.55 PENDING False -\n",
|
|
"\n",
|
|
"감사 로그 11건 기록, 로깅 성공률 100%, 해시 체인 검증: (True, -1)\n",
|
|
"5번째 기록 변조 후 검증(False, 깨진 위치): (False, 5)\n",
|
|
"\n",
|
|
"본 워크플로우의 '승인'은 상급 지휘계통으로 인가를 요청하는 단계까지이며, 실제 교전은 교전수칙·지휘계통에 따른다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 08. 대규모 드론 포화 공격 상황 모의 가상 스트레스 시뮬레이션 및 복원력 테스트\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"100기 이상의 드론 포화 공격, 영상 프레임 지연, 통신 패킷 손실 등 극한의 전장 과부하 상황을 가상 주입하여 시스템 탐지 복원력과 처리 지연시간을 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **100기 이상 드론 포화 공격, 프레임 지연·손실, 통신 패킷 손실 상황에서 탐지 파이프라인의 복원력과 처리 지연을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **평시, 드론 40·80·120기 포화, 비 오는 조건, 프레임 10% 손실, 패킷 20% 손실, 센서 두절 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `tests/ops01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"100기 이상 드론 포화 공격, 프레임 지연·손실, 통신 패킷 손실을 가상으로 주입해 탐지 파이프라인이 멈추지 않는지(다운 0건), 경보가 유실되지 않는지(유실률 0%), 처리 지연과 성능 저하 모드가 어떻게 변하는지 검증한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)\n",
|
|
"# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"H = W = 64\n",
|
|
"PROFILE = {\"drone\": ((2, 3), (170, 225)), \"bird\": ((2, 3), (110, 150)),\n",
|
|
" \"civil\": ((4, 6), (190, 240)), \"clutter\": ((2, 4), (140, 190))}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _new_obj(cls, rng, dive=False, v=None, xy=None):\n",
|
|
" (s0, s1), (h0, h1) = PROFILE[cls]\n",
|
|
" x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))\n",
|
|
" speed = {\"drone\": 1.3, \"bird\": 0.9, \"civil\": 0.35, \"clutter\": 0.0}[cls]\n",
|
|
" vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)\n",
|
|
" return {\"cls\": cls, \"size\": int(rng.integers(s0, s1 + 1)), \"heat\": float(rng.uniform(h0, h1)),\n",
|
|
" \"x\": float(x), \"y\": float(y), \"vx\": float(vx), \"vy\": float(vy), \"dive\": dive}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _noise(img, cond, rng):\n",
|
|
" f = img.astype(float)\n",
|
|
" if cond == \"night\":\n",
|
|
" f = f * 0.8 + rng.normal(0, 14, f.shape)\n",
|
|
" elif cond == \"fog\":\n",
|
|
" f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)\n",
|
|
" elif cond == \"rain\":\n",
|
|
" f = f + rng.normal(0, 8, f.shape)\n",
|
|
" for _ in range(20):\n",
|
|
" x, y0 = rng.integers(0, W), rng.integers(0, H - 8)\n",
|
|
" f[y0:y0 + 8, x] += 45\n",
|
|
" else:\n",
|
|
" f = f + rng.normal(0, 6, f.shape)\n",
|
|
" return np.clip(f, 0, 255).astype(np.uint8)\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_sequence(seq_id, cond, rng, kind=\"mixed\", n_frames=30, n_drone=None, n_bird=None):\n",
|
|
" \"\"\"시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation\"\"\"\n",
|
|
" objs = []\n",
|
|
" if kind == \"swarm\":\n",
|
|
" v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)\n",
|
|
" objs = [_new_obj(\"drone\", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]\n",
|
|
" objs.append(_new_obj(\"bird\", rng))\n",
|
|
" elif kind == \"dive\":\n",
|
|
" d = _new_obj(\"drone\", rng, dive=True)\n",
|
|
" d[\"y\"] = float(rng.uniform(4, 14))\n",
|
|
" objs = [d, _new_obj(\"bird\", rng), _new_obj(\"clutter\", rng)]\n",
|
|
" elif kind == \"saturation\":\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(n_drone)] + [_new_obj(\"bird\", rng) for _ in range(n_bird)]\n",
|
|
" else:\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(int(rng.integers(1, 3)))]\n",
|
|
" objs += [_new_obj(\"bird\", rng) for _ in range(int(rng.integers(0, 3)))]\n",
|
|
" if rng.random() < 0.5:\n",
|
|
" objs.append(_new_obj(\"civil\", rng))\n",
|
|
" if rng.random() < 0.6:\n",
|
|
" objs.append(_new_obj(\"clutter\", rng))\n",
|
|
" frames, labels = np.zeros((n_frames, H, W), np.uint8), []\n",
|
|
" base = rng.normal(40, 6, (H, W))\n",
|
|
" for t in range(n_frames):\n",
|
|
" canvas = base.copy()\n",
|
|
" for i, o in enumerate(objs):\n",
|
|
" if t > 0:\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" o[\"vx\"] = float(np.clip(o[\"vx\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" o[\"vy\"] = float(np.clip(o[\"vy\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" if o[\"dive\"] and t > 10:\n",
|
|
" o[\"vy\"] += 0.18\n",
|
|
" o[\"x\"] = float(np.clip(o[\"x\"] + o[\"vx\"], 1, W - o[\"size\"] - 1))\n",
|
|
" o[\"y\"] = float(np.clip(o[\"y\"] + o[\"vy\"], 1, H - o[\"size\"] - 1))\n",
|
|
" heat = o[\"heat\"]\n",
|
|
" if o[\"cls\"] == \"clutter\" and rng.random() < 0.3:\n",
|
|
" heat *= 0.7\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" heat *= float(rng.uniform(0.85, 1.1))\n",
|
|
" x, y, s = int(round(o[\"x\"])), int(round(o[\"y\"])), o[\"size\"]\n",
|
|
" canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)\n",
|
|
" labels.append({\"frame\": t, \"track\": f\"{seq_id}_T{i:02d}\", \"class\": o[\"cls\"], \"bbox\": [x, y, s, s],\n",
|
|
" \"pattern\": \"dive\" if o[\"dive\"] else (\"swarm\" if kind == \"swarm\" and o[\"cls\"] == \"drone\" else \"normal\")})\n",
|
|
" frames[t] = _noise(canvas, cond, rng)\n",
|
|
" return frames, labels\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=24, seed=7):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" conds = [\"clear\", \"night\", \"fog\", \"rain\"]\n",
|
|
" out = []\n",
|
|
" for i in range(n):\n",
|
|
" kind = \"swarm\" if i % 8 == 5 else (\"dive\" if i % 8 == 6 else \"mixed\")\n",
|
|
" f, lab = make_sequence(f\"SEQ{i:03d}\", conds[i % 4], rng, kind)\n",
|
|
" out.append({\"id\": f\"SEQ{i:03d}\", \"cond\": conds[i % 4], \"kind\": kind, \"frames\": f, \"labels\": lab})\n",
|
|
" return out\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리·탐지 공통 함수 ----\n",
|
|
"def preprocess(frame, cond):\n",
|
|
" f = frame.astype(float)\n",
|
|
" if cond == \"rain\": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)\n",
|
|
" f = ndimage.grey_opening(f, size=(1, 2))\n",
|
|
" lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장\n",
|
|
" return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n",
|
|
"\n",
|
|
"\n",
|
|
"def detect(frame, cond, k=2.0, min_area=3, max_area=60):\n",
|
|
" img = preprocess(frame, cond)\n",
|
|
" lab, _ = ndimage.label(img > img.mean() + k * img.std())\n",
|
|
" dets = []\n",
|
|
" for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n",
|
|
" area = int((lab[sl] == i).sum())\n",
|
|
" if min_area <= area <= max_area:\n",
|
|
" y, x = sl[0].start, sl[1].start\n",
|
|
" dets.append({\"bbox\": [x, y, sl[1].stop - x, sl[0].stop - y], \"area\": area,\n",
|
|
" \"thermal\": float(frame[sl][lab[sl] == i].max())})\n",
|
|
" return dets\n",
|
|
"\n",
|
|
"\n",
|
|
"def iou(a, b):\n",
|
|
" ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))\n",
|
|
" iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))\n",
|
|
" inter = ix * iy\n",
|
|
" return inter / (a[2] * a[3] + b[2] * b[3] - inter)\n",
|
|
"\n",
|
|
"# ---- 추적·특징·패턴 공통 함수 ----\n",
|
|
"def center(b):\n",
|
|
" return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])\n",
|
|
"\n",
|
|
"\n",
|
|
"def track(per_frame, gate=6.0, max_missed=2, min_len=5):\n",
|
|
" \"\"\"예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다.\"\"\"\n",
|
|
" active, done = [], []\n",
|
|
" for t, dets in enumerate(per_frame):\n",
|
|
" used = set()\n",
|
|
" for tr in sorted(active, key=lambda x: -len(x[\"t\"])):\n",
|
|
" last = center(tr[\"b\"][-1])\n",
|
|
" vel = last - center(tr[\"b\"][-2]) if len(tr[\"b\"]) > 1 else np.zeros(2)\n",
|
|
" pred = last + vel * (t - tr[\"t\"][-1])\n",
|
|
" cand = [(np.linalg.norm(center(d[\"bbox\"]) - pred), j) for j, d in enumerate(dets) if j not in used]\n",
|
|
" dist, j = min(cand, default=(gate, -1))\n",
|
|
" if j >= 0 and dist < gate:\n",
|
|
" used.add(j)\n",
|
|
" tr[\"t\"].append(t); tr[\"b\"].append(dets[j][\"bbox\"])\n",
|
|
" tr[\"area\"].append(dets[j][\"area\"]); tr[\"heat\"].append(dets[j][\"thermal\"])\n",
|
|
" still = []\n",
|
|
" for tr in active:\n",
|
|
" (still if t - tr[\"t\"][-1] <= max_missed else done).append(tr)\n",
|
|
" active = still\n",
|
|
" for j, d in enumerate(dets):\n",
|
|
" if j not in used:\n",
|
|
" active.append({\"t\": [t], \"b\": [d[\"bbox\"]], \"area\": [d[\"area\"]], \"heat\": [d[\"thermal\"]]})\n",
|
|
" return [tr for tr in done + active if len(tr[\"t\"]) >= min_len]\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATURES = [\"mean_speed\", \"speed_std\", \"turn\", \"straight\", \"vy_accel\", \"area\", \"heat\", \"heat_std\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def features(tr):\n",
|
|
" c = np.array([center(b) for b in tr[\"b\"]]); f = np.array(tr[\"t\"], float)\n",
|
|
" v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]\n",
|
|
" sp = np.linalg.norm(v, axis=1)\n",
|
|
" k = 3 if len(c) > 4 else 1\n",
|
|
" v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]\n",
|
|
" hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2\n",
|
|
" turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]\n",
|
|
" half = len(v) // 2\n",
|
|
" return {\"mean_speed\": sp.mean(), \"speed_std\": sp.std(), \"turn\": turns.mean() if len(turns) else 0.0,\n",
|
|
" \"straight\": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),\n",
|
|
" \"vy_accel\": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,\n",
|
|
" \"area\": np.mean(tr[\"area\"]), \"heat\": np.mean(tr[\"heat\"]), \"heat_std\": np.std(tr[\"heat\"]),\n",
|
|
" \"vx\": v[:, 0].mean(), \"vy\": v[:, 1].mean(), \"x\": c[-1, 0], \"y\": c[-1, 1]}\n",
|
|
"\n",
|
|
"\n",
|
|
"def pattern(f):\n",
|
|
" if f[\"mean_speed\"] < 0.25:\n",
|
|
" return \"static\"\n",
|
|
" if f[\"vy_accel\"] > 0.06:\n",
|
|
" return \"dive\"\n",
|
|
" if f[\"turn\"] > 0.4 or f[\"straight\"] < 0.6:\n",
|
|
" return \"erratic\"\n",
|
|
" return \"steady\"\n",
|
|
"\n",
|
|
"\n",
|
|
"def swarms(trs, vel=0.2, dist=18.0, min_n=3):\n",
|
|
" \"\"\"속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다.\"\"\"\n",
|
|
" cand = [i for i, t in enumerate(trs) if t[\"pattern\"] != \"static\"]\n",
|
|
" adj = {i: set() for i in cand}\n",
|
|
" for a in cand:\n",
|
|
" for b in cand:\n",
|
|
" fa, fb = trs[a][\"f\"], trs[b][\"f\"]\n",
|
|
" if a < b and np.hypot(fa[\"vx\"] - fb[\"vx\"], fa[\"vy\"] - fb[\"vy\"]) < vel and np.hypot(fa[\"x\"] - fb[\"x\"], fa[\"y\"] - fb[\"y\"]) < dist:\n",
|
|
" adj[a].add(b); adj[b].add(a)\n",
|
|
" seen, groups = set(), []\n",
|
|
" for i in cand:\n",
|
|
" if i in seen:\n",
|
|
" continue\n",
|
|
" stack, comp = [i], []\n",
|
|
" while stack:\n",
|
|
" k = stack.pop()\n",
|
|
" if k not in seen:\n",
|
|
" seen.add(k); comp.append(k); stack += list(adj[k] - seen)\n",
|
|
" if len(comp) >= min_n:\n",
|
|
" groups.append(comp)\n",
|
|
" return groups\n",
|
|
"\n",
|
|
"\n",
|
|
"def analyze(s, k=2.0):\n",
|
|
" \"\"\"시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용).\"\"\"\n",
|
|
" per = [detect(fr, s[\"cond\"], k) if fr is not None else [] for fr in s[\"frames\"]]\n",
|
|
" trs = track(per)\n",
|
|
" for tr in trs:\n",
|
|
" tr[\"f\"] = features(tr); tr[\"pattern\"] = pattern(tr[\"f\"]); tr[\"swarm\"] = None; tr[\"seq\"] = s[\"id\"]\n",
|
|
" votes = {}\n",
|
|
" for t, b in zip(tr[\"t\"], tr[\"b\"]):\n",
|
|
" best = max(((iou(b, l[\"bbox\"]), l) for l in s[\"labels\"] if l[\"frame\"] == t), default=(0, None), key=lambda x: x[0])\n",
|
|
" key = (best[1][\"track\"], best[1][\"class\"], best[1][\"pattern\"]) if best[0] > 0.05 else (\"none\", \"noise\", \"normal\")\n",
|
|
" votes[key] = votes.get(key, 0) + 1\n",
|
|
" tr[\"gt_track\"], tr[\"gt_class\"], tr[\"gt_pattern\"] = max(votes, key=votes.get)\n",
|
|
" for g, comp in enumerate(swarms(trs)):\n",
|
|
" for i in comp:\n",
|
|
" trs[i][\"swarm\"] = g\n",
|
|
" if trs[i][\"pattern\"] in (\"steady\", \"erratic\"):\n",
|
|
" trs[i][\"pattern\"] = \"swarm\"\n",
|
|
" return trs\n",
|
|
"\n",
|
|
"\n",
|
|
"QUEUE_CAPACITY = 30\n",
|
|
"\n",
|
|
"\n",
|
|
"def run(frames, cond, drop_rate=0.0, packet_loss=0.0, seed=0):\n",
|
|
" \"\"\"손실 주입 후 파이프라인 1회 실행. 예외가 나면 다운으로 기록한다.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" drop = set(rng.choice(len(frames), int(round(drop_rate * len(frames))), replace=False).tolist())\n",
|
|
" fr = [None if i in drop else f for i, f in enumerate(frames)]\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" try:\n",
|
|
" s = {\"id\": \"STRESS\", \"cond\": cond, \"frames\": fr, \"labels\": []}\n",
|
|
" per = [detect(f, cond) if f is not None else [] for f in fr]\n",
|
|
" trs = track(per)\n",
|
|
" alerts = [t for t in trs if pattern(features(t)) != \"static\"]\n",
|
|
" sent = [a for a in alerts if rng.random() >= packet_loss] # 전송 중 손실\n",
|
|
" lost = [a for a in alerts if a not in sent]\n",
|
|
" resent = lost # 수신 확인 없는 경보는 재전송\n",
|
|
" received = len(sent) + len(resent)\n",
|
|
" down = 0\n",
|
|
" except Exception as e: # 다운 집계\n",
|
|
" alerts, received, down = [], 0, 1\n",
|
|
" print(\"다운:\", e)\n",
|
|
" ms = (time.perf_counter() - t0) * 1000 / len(frames)\n",
|
|
" reasons = []\n",
|
|
" n_drop = sum(f is None for f in fr)\n",
|
|
" if n_drop / len(fr) > 0.05:\n",
|
|
" reasons.append(f\"프레임 손실 {n_drop / len(fr):.0%}\")\n",
|
|
" if len(alerts) > QUEUE_CAPACITY:\n",
|
|
" reasons.append(f\"경보 {len(alerts)}건 > 검토용량 {QUEUE_CAPACITY}\")\n",
|
|
" return {\"경보\": len(alerts), \"수신\": received, \"유실률\": 0 if not alerts else round(1 - received / len(alerts), 3),\n",
|
|
" \"다운\": down, \"프레임당 ms\": round(ms, 2), \"성능저하 모드\": \"; \".join(reasons) or \"-\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"rng = np.random.default_rng(1)\n",
|
|
"cases = []\n",
|
|
"base, _ = make_sequence(\"S0\", \"clear\", rng, \"mixed\")\n",
|
|
"cases.append((\"평시(비행체 1~5기)\", base, \"clear\", 0, 0))\n",
|
|
"for n in [40, 80, 120]:\n",
|
|
" f, _ = make_sequence(\"S\", \"clear\", rng, \"saturation\", n_drone=n, n_bird=n // 4)\n",
|
|
" cases.append((f\"포화 드론 {n}기\", f, \"clear\", 0, 0))\n",
|
|
"f120, _ = make_sequence(\"S\", \"rain\", rng, \"saturation\", n_drone=120, n_bird=30)\n",
|
|
"cases.append((\"포화 120기 + 비\", f120, \"rain\", 0, 0))\n",
|
|
"cases.append((\"포화 120기 + 프레임 10% 손실\", f120, \"rain\", 0.10, 0))\n",
|
|
"cases.append((\"포화 120기 + 패킷 20% 손실\", f120, \"rain\", 0, 0.20))\n",
|
|
"cases.append((\"전 프레임 손실(센서 두절)\", f120, \"rain\", 1.0, 0))\n",
|
|
"\n",
|
|
"rows = [{\"상황\": name, **run(fr, c, d, p, seed=i)} for i, (name, fr, c, d, p) in enumerate(cases)]\n",
|
|
"df = pd.DataFrame(rows)\n",
|
|
"print(df.to_string(index=False))\n",
|
|
"print(f\"\\n시스템 다운 {df['다운'].sum()}건, 최대 유실률 {df['유실률'].max():.0%}, 최대 프레임당 처리 {df['프레임당 ms'].max()} ms\")\n",
|
|
"print(\"관찰: 64x64 영상에 120기가 겹치면 표적끼리 붙어 경보 수가 실제보다 적게 잡힌다(해상도 한계). 성능저하 모드가 표시되면 운용자 추가 투입·검토 우선순위 적용이 필요하다.\")\n",
|
|
"\n",
|
|
"plt.figure(figsize=(8, 4))\n",
|
|
"plt.bar(range(len(df)), df[\"프레임당 ms\"], color=[\"tab:red\" if m != \"-\" else \"tab:blue\" for m in df[\"성능저하 모드\"]])\n",
|
|
"plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))])\n",
|
|
"plt.ylabel(\"ms per frame\"); plt.title(\"Stress test latency (red = degraded mode)\")\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
" 상황 경보 수신 유실률 다운 프레임당 ms 성능저하 모드\n",
|
|
" 평시(비행체 1~5기) 1 1 0.0 0 0.89 -\n",
|
|
" 포화 드론 40기 57 57 0.0 0 4.31 경보 57건 > 검토용량 30\n",
|
|
" 포화 드론 80기 97 97 0.0 0 9.48 경보 97건 > 검토용량 30\n",
|
|
" 포화 드론 120기 100 100 0.0 0 10.27 경보 100건 > 검토용량 30\n",
|
|
" 포화 120기 + 비 96 96 0.0 0 11.60 경보 96건 > 검토용량 30\n",
|
|
"포화 120기 + 프레임 10% 손실 97 97 0.0 0 8.17 프레임 손실 10%; 경보 97건 > 검토용량 30\n",
|
|
" 포화 120기 + 패킷 20% 손실 96 96 0.0 0 10.51 경보 96건 > 검토용량 30\n",
|
|
" 전 프레임 손실(센서 두절) 0 0 0.0 0 0.00 프레임 손실 100%\n",
|
|
"\n",
|
|
"시스템 다운 0건, 최대 유실률 0%, 최대 프레임당 처리 11.6 ms\n",
|
|
"관찰: 64x64 영상에 120기가 겹치면 표적끼리 붙어 경보 수가 실제보다 적게 잡힌다(해상도 한계). 성능저하 모드가 표시되면 운용자 추가 투입·검토 우선순위 적용이 필요하다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 09. AAR 사후검토 기반 미탐·오탐 공중표적 오류 원인 분석 및 파라미터 재보정\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"AAR 사후검토 일지를 기반으로 모의 교전 중 발생한 미탐 및 오경보 사례의 근본 원인을 분석하고 탐지 임계값과 가중치 파라미터를 역환류(Feedback) 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토 일지로 미탐·오경보의 근본 원인을 분석하고 탐지 임계값과 경보 임계값을 역환류 보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **'놓치지 않기'를 우선한 초기 설정으로 모의 운용한 뒤, 오경보는 실제 정체·기상별로, 미탐은 추적 실패·점수 미달로 분류한 AAR 일지 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `docs/ops01_aar_report.md 및 configs/ops01_tuned_params.yaml` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"AAR(사후검토) 일지 형식으로 모의 운용 중 발생한 미탐·오경보 사례를 원인별로 분류하고, 탐지 임계값 k와 경보 임계값을 격자 탐색으로 재보정(역환류)해 개선 전후 성능과 재발 여부를 비교한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.linear_model import LogisticRegression\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)\n",
|
|
"# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"H = W = 64\n",
|
|
"PROFILE = {\"drone\": ((2, 3), (170, 225)), \"bird\": ((2, 3), (110, 150)),\n",
|
|
" \"civil\": ((4, 6), (190, 240)), \"clutter\": ((2, 4), (140, 190))}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _new_obj(cls, rng, dive=False, v=None, xy=None):\n",
|
|
" (s0, s1), (h0, h1) = PROFILE[cls]\n",
|
|
" x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))\n",
|
|
" speed = {\"drone\": 1.3, \"bird\": 0.9, \"civil\": 0.35, \"clutter\": 0.0}[cls]\n",
|
|
" vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)\n",
|
|
" return {\"cls\": cls, \"size\": int(rng.integers(s0, s1 + 1)), \"heat\": float(rng.uniform(h0, h1)),\n",
|
|
" \"x\": float(x), \"y\": float(y), \"vx\": float(vx), \"vy\": float(vy), \"dive\": dive}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _noise(img, cond, rng):\n",
|
|
" f = img.astype(float)\n",
|
|
" if cond == \"night\":\n",
|
|
" f = f * 0.8 + rng.normal(0, 14, f.shape)\n",
|
|
" elif cond == \"fog\":\n",
|
|
" f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)\n",
|
|
" elif cond == \"rain\":\n",
|
|
" f = f + rng.normal(0, 8, f.shape)\n",
|
|
" for _ in range(20):\n",
|
|
" x, y0 = rng.integers(0, W), rng.integers(0, H - 8)\n",
|
|
" f[y0:y0 + 8, x] += 45\n",
|
|
" else:\n",
|
|
" f = f + rng.normal(0, 6, f.shape)\n",
|
|
" return np.clip(f, 0, 255).astype(np.uint8)\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_sequence(seq_id, cond, rng, kind=\"mixed\", n_frames=30, n_drone=None, n_bird=None):\n",
|
|
" \"\"\"시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation\"\"\"\n",
|
|
" objs = []\n",
|
|
" if kind == \"swarm\":\n",
|
|
" v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)\n",
|
|
" objs = [_new_obj(\"drone\", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]\n",
|
|
" objs.append(_new_obj(\"bird\", rng))\n",
|
|
" elif kind == \"dive\":\n",
|
|
" d = _new_obj(\"drone\", rng, dive=True)\n",
|
|
" d[\"y\"] = float(rng.uniform(4, 14))\n",
|
|
" objs = [d, _new_obj(\"bird\", rng), _new_obj(\"clutter\", rng)]\n",
|
|
" elif kind == \"saturation\":\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(n_drone)] + [_new_obj(\"bird\", rng) for _ in range(n_bird)]\n",
|
|
" else:\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(int(rng.integers(1, 3)))]\n",
|
|
" objs += [_new_obj(\"bird\", rng) for _ in range(int(rng.integers(0, 3)))]\n",
|
|
" if rng.random() < 0.5:\n",
|
|
" objs.append(_new_obj(\"civil\", rng))\n",
|
|
" if rng.random() < 0.6:\n",
|
|
" objs.append(_new_obj(\"clutter\", rng))\n",
|
|
" frames, labels = np.zeros((n_frames, H, W), np.uint8), []\n",
|
|
" base = rng.normal(40, 6, (H, W))\n",
|
|
" for t in range(n_frames):\n",
|
|
" canvas = base.copy()\n",
|
|
" for i, o in enumerate(objs):\n",
|
|
" if t > 0:\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" o[\"vx\"] = float(np.clip(o[\"vx\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" o[\"vy\"] = float(np.clip(o[\"vy\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" if o[\"dive\"] and t > 10:\n",
|
|
" o[\"vy\"] += 0.18\n",
|
|
" o[\"x\"] = float(np.clip(o[\"x\"] + o[\"vx\"], 1, W - o[\"size\"] - 1))\n",
|
|
" o[\"y\"] = float(np.clip(o[\"y\"] + o[\"vy\"], 1, H - o[\"size\"] - 1))\n",
|
|
" heat = o[\"heat\"]\n",
|
|
" if o[\"cls\"] == \"clutter\" and rng.random() < 0.3:\n",
|
|
" heat *= 0.7\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" heat *= float(rng.uniform(0.85, 1.1))\n",
|
|
" x, y, s = int(round(o[\"x\"])), int(round(o[\"y\"])), o[\"size\"]\n",
|
|
" canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)\n",
|
|
" labels.append({\"frame\": t, \"track\": f\"{seq_id}_T{i:02d}\", \"class\": o[\"cls\"], \"bbox\": [x, y, s, s],\n",
|
|
" \"pattern\": \"dive\" if o[\"dive\"] else (\"swarm\" if kind == \"swarm\" and o[\"cls\"] == \"drone\" else \"normal\")})\n",
|
|
" frames[t] = _noise(canvas, cond, rng)\n",
|
|
" return frames, labels\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=24, seed=7):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" conds = [\"clear\", \"night\", \"fog\", \"rain\"]\n",
|
|
" out = []\n",
|
|
" for i in range(n):\n",
|
|
" kind = \"swarm\" if i % 8 == 5 else (\"dive\" if i % 8 == 6 else \"mixed\")\n",
|
|
" f, lab = make_sequence(f\"SEQ{i:03d}\", conds[i % 4], rng, kind)\n",
|
|
" out.append({\"id\": f\"SEQ{i:03d}\", \"cond\": conds[i % 4], \"kind\": kind, \"frames\": f, \"labels\": lab})\n",
|
|
" return out\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리·탐지 공통 함수 ----\n",
|
|
"def preprocess(frame, cond):\n",
|
|
" f = frame.astype(float)\n",
|
|
" if cond == \"rain\": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)\n",
|
|
" f = ndimage.grey_opening(f, size=(1, 2))\n",
|
|
" lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장\n",
|
|
" return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n",
|
|
"\n",
|
|
"\n",
|
|
"def detect(frame, cond, k=2.0, min_area=3, max_area=60):\n",
|
|
" img = preprocess(frame, cond)\n",
|
|
" lab, _ = ndimage.label(img > img.mean() + k * img.std())\n",
|
|
" dets = []\n",
|
|
" for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n",
|
|
" area = int((lab[sl] == i).sum())\n",
|
|
" if min_area <= area <= max_area:\n",
|
|
" y, x = sl[0].start, sl[1].start\n",
|
|
" dets.append({\"bbox\": [x, y, sl[1].stop - x, sl[0].stop - y], \"area\": area,\n",
|
|
" \"thermal\": float(frame[sl][lab[sl] == i].max())})\n",
|
|
" return dets\n",
|
|
"\n",
|
|
"\n",
|
|
"def iou(a, b):\n",
|
|
" ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))\n",
|
|
" iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))\n",
|
|
" inter = ix * iy\n",
|
|
" return inter / (a[2] * a[3] + b[2] * b[3] - inter)\n",
|
|
"\n",
|
|
"# ---- 추적·특징·패턴 공통 함수 ----\n",
|
|
"def center(b):\n",
|
|
" return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])\n",
|
|
"\n",
|
|
"\n",
|
|
"def track(per_frame, gate=6.0, max_missed=2, min_len=5):\n",
|
|
" \"\"\"예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다.\"\"\"\n",
|
|
" active, done = [], []\n",
|
|
" for t, dets in enumerate(per_frame):\n",
|
|
" used = set()\n",
|
|
" for tr in sorted(active, key=lambda x: -len(x[\"t\"])):\n",
|
|
" last = center(tr[\"b\"][-1])\n",
|
|
" vel = last - center(tr[\"b\"][-2]) if len(tr[\"b\"]) > 1 else np.zeros(2)\n",
|
|
" pred = last + vel * (t - tr[\"t\"][-1])\n",
|
|
" cand = [(np.linalg.norm(center(d[\"bbox\"]) - pred), j) for j, d in enumerate(dets) if j not in used]\n",
|
|
" dist, j = min(cand, default=(gate, -1))\n",
|
|
" if j >= 0 and dist < gate:\n",
|
|
" used.add(j)\n",
|
|
" tr[\"t\"].append(t); tr[\"b\"].append(dets[j][\"bbox\"])\n",
|
|
" tr[\"area\"].append(dets[j][\"area\"]); tr[\"heat\"].append(dets[j][\"thermal\"])\n",
|
|
" still = []\n",
|
|
" for tr in active:\n",
|
|
" (still if t - tr[\"t\"][-1] <= max_missed else done).append(tr)\n",
|
|
" active = still\n",
|
|
" for j, d in enumerate(dets):\n",
|
|
" if j not in used:\n",
|
|
" active.append({\"t\": [t], \"b\": [d[\"bbox\"]], \"area\": [d[\"area\"]], \"heat\": [d[\"thermal\"]]})\n",
|
|
" return [tr for tr in done + active if len(tr[\"t\"]) >= min_len]\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATURES = [\"mean_speed\", \"speed_std\", \"turn\", \"straight\", \"vy_accel\", \"area\", \"heat\", \"heat_std\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def features(tr):\n",
|
|
" c = np.array([center(b) for b in tr[\"b\"]]); f = np.array(tr[\"t\"], float)\n",
|
|
" v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]\n",
|
|
" sp = np.linalg.norm(v, axis=1)\n",
|
|
" k = 3 if len(c) > 4 else 1\n",
|
|
" v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]\n",
|
|
" hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2\n",
|
|
" turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]\n",
|
|
" half = len(v) // 2\n",
|
|
" return {\"mean_speed\": sp.mean(), \"speed_std\": sp.std(), \"turn\": turns.mean() if len(turns) else 0.0,\n",
|
|
" \"straight\": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),\n",
|
|
" \"vy_accel\": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,\n",
|
|
" \"area\": np.mean(tr[\"area\"]), \"heat\": np.mean(tr[\"heat\"]), \"heat_std\": np.std(tr[\"heat\"]),\n",
|
|
" \"vx\": v[:, 0].mean(), \"vy\": v[:, 1].mean(), \"x\": c[-1, 0], \"y\": c[-1, 1]}\n",
|
|
"\n",
|
|
"\n",
|
|
"def pattern(f):\n",
|
|
" if f[\"mean_speed\"] < 0.25:\n",
|
|
" return \"static\"\n",
|
|
" if f[\"vy_accel\"] > 0.06:\n",
|
|
" return \"dive\"\n",
|
|
" if f[\"turn\"] > 0.4 or f[\"straight\"] < 0.6:\n",
|
|
" return \"erratic\"\n",
|
|
" return \"steady\"\n",
|
|
"\n",
|
|
"\n",
|
|
"def swarms(trs, vel=0.2, dist=18.0, min_n=3):\n",
|
|
" \"\"\"속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다.\"\"\"\n",
|
|
" cand = [i for i, t in enumerate(trs) if t[\"pattern\"] != \"static\"]\n",
|
|
" adj = {i: set() for i in cand}\n",
|
|
" for a in cand:\n",
|
|
" for b in cand:\n",
|
|
" fa, fb = trs[a][\"f\"], trs[b][\"f\"]\n",
|
|
" if a < b and np.hypot(fa[\"vx\"] - fb[\"vx\"], fa[\"vy\"] - fb[\"vy\"]) < vel and np.hypot(fa[\"x\"] - fb[\"x\"], fa[\"y\"] - fb[\"y\"]) < dist:\n",
|
|
" adj[a].add(b); adj[b].add(a)\n",
|
|
" seen, groups = set(), []\n",
|
|
" for i in cand:\n",
|
|
" if i in seen:\n",
|
|
" continue\n",
|
|
" stack, comp = [i], []\n",
|
|
" while stack:\n",
|
|
" k = stack.pop()\n",
|
|
" if k not in seen:\n",
|
|
" seen.add(k); comp.append(k); stack += list(adj[k] - seen)\n",
|
|
" if len(comp) >= min_n:\n",
|
|
" groups.append(comp)\n",
|
|
" return groups\n",
|
|
"\n",
|
|
"\n",
|
|
"def analyze(s, k=2.0):\n",
|
|
" \"\"\"시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용).\"\"\"\n",
|
|
" per = [detect(fr, s[\"cond\"], k) if fr is not None else [] for fr in s[\"frames\"]]\n",
|
|
" trs = track(per)\n",
|
|
" for tr in trs:\n",
|
|
" tr[\"f\"] = features(tr); tr[\"pattern\"] = pattern(tr[\"f\"]); tr[\"swarm\"] = None; tr[\"seq\"] = s[\"id\"]\n",
|
|
" votes = {}\n",
|
|
" for t, b in zip(tr[\"t\"], tr[\"b\"]):\n",
|
|
" best = max(((iou(b, l[\"bbox\"]), l) for l in s[\"labels\"] if l[\"frame\"] == t), default=(0, None), key=lambda x: x[0])\n",
|
|
" key = (best[1][\"track\"], best[1][\"class\"], best[1][\"pattern\"]) if best[0] > 0.05 else (\"none\", \"noise\", \"normal\")\n",
|
|
" votes[key] = votes.get(key, 0) + 1\n",
|
|
" tr[\"gt_track\"], tr[\"gt_class\"], tr[\"gt_pattern\"] = max(votes, key=votes.get)\n",
|
|
" for g, comp in enumerate(swarms(trs)):\n",
|
|
" for i in comp:\n",
|
|
" trs[i][\"swarm\"] = g\n",
|
|
" if trs[i][\"pattern\"] in (\"steady\", \"erratic\"):\n",
|
|
" trs[i][\"pattern\"] = \"swarm\"\n",
|
|
" return trs\n",
|
|
"\n",
|
|
"\n",
|
|
"def X_of(trs):\n",
|
|
" return np.array([[t[\"f\"][k] for k in FEATURES] for t in trs])\n",
|
|
"\n",
|
|
"\n",
|
|
"def run_system(k, train_seed=7, test_seed=11):\n",
|
|
" \"\"\"탐지 임계값 k로 학습·검증 데이터를 처리하고, 검증 궤적마다 드론 확률(prob)을 붙인다.\"\"\"\n",
|
|
" tr = [t for s in make_dataset(24, train_seed) for t in analyze(s, k)]\n",
|
|
" te_data = make_dataset(24, test_seed)\n",
|
|
" te = [t for s in te_data for t in analyze(s, k)]\n",
|
|
" m = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=1000, random_state=0))\n",
|
|
" m.fit(X_of(tr), [t[\"gt_class\"] == \"drone\" for t in tr])\n",
|
|
" for t, p in zip(te, m.predict_proba(X_of(te))[:, 1]):\n",
|
|
" t[\"prob\"] = p\n",
|
|
" t[\"cond\"] = next(s[\"cond\"] for s in te_data if s[\"id\"] == t[\"seq\"])\n",
|
|
" drones = {l[\"track\"] for s in te_data for l in s[\"labels\"] if l[\"class\"] == \"drone\"}\n",
|
|
" return te, drones\n",
|
|
"\n",
|
|
"\n",
|
|
"def set_alerts(te, thr):\n",
|
|
" for t in te:\n",
|
|
" t[\"alert\"] = t[\"pattern\"] != \"static\" and t[\"prob\"] >= thr\n",
|
|
" return te\n",
|
|
"\n",
|
|
"\n",
|
|
"def summarize(te, drones):\n",
|
|
" alerts = [t for t in te if t[\"alert\"]]\n",
|
|
" tp = {t[\"gt_track\"] for t in alerts if t[\"gt_class\"] == \"drone\"}\n",
|
|
" far = sum(t[\"gt_class\"] != \"drone\" for t in alerts) / max(len(alerts), 1)\n",
|
|
" rec = len(tp) / len(drones)\n",
|
|
" acc = 2 * (1 - far) * rec / max((1 - far) + rec, 1e-9)\n",
|
|
" return {\"경보\": len(alerts), \"오경보율\": round(far, 3), \"재현율\": round(rec, 3), \"종합정확도(F1)\": round(acc, 3)}\n",
|
|
"\n",
|
|
"\n",
|
|
"def aar_log(te, drones):\n",
|
|
" \"\"\"AAR 일지: 오경보는 실제 정체·기상별로, 미탐은 원인별로 분류.\"\"\"\n",
|
|
" rows = []\n",
|
|
" for t in te:\n",
|
|
" if t[\"alert\"] and t[\"gt_class\"] != \"drone\":\n",
|
|
" rows.append({\"구분\": \"오경보\", \"원인\": f\"{t['gt_class']} 오인\", \"기상\": t[\"cond\"], \"궤적\": t[\"seq\"]})\n",
|
|
" tracked = {t[\"gt_track\"] for t in te if t[\"gt_class\"] == \"drone\"}\n",
|
|
" alerted = {t[\"gt_track\"] for t in te if t[\"gt_class\"] == \"drone\" and t[\"alert\"]}\n",
|
|
" for d in sorted(drones):\n",
|
|
" if d not in tracked:\n",
|
|
" rows.append({\"구분\": \"미탐\", \"원인\": \"추적 실패(탐지 누락)\", \"기상\": \"-\", \"궤적\": d})\n",
|
|
" elif d not in alerted:\n",
|
|
" rows.append({\"구분\": \"미탐\", \"원인\": \"신뢰도 점수 미달\", \"기상\": \"-\", \"궤적\": d})\n",
|
|
" return pd.DataFrame(rows, columns=[\"구분\", \"원인\", \"기상\", \"궤적\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"# 1) 초기 설정(k=2.0, 경보 임계값 0.3: '놓치지 않기'를 우선한 설정)으로 모의 운용\n",
|
|
"before_te, drones = run_system(2.0)\n",
|
|
"set_alerts(before_te, 0.3)\n",
|
|
"before = summarize(before_te, drones)\n",
|
|
"log = aar_log(before_te, drones)\n",
|
|
"print(\"[1. 무엇이 일어났나] 초기 설정 결과:\", before)\n",
|
|
"print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").to_string())\n",
|
|
"print(\"\\n[2. 왜 일어났나] 오경보 기상 분포:\", log[log[\"구분\"] == \"오경보\"][\"기상\"].value_counts().to_dict())\n",
|
|
"\n",
|
|
"# 2) 재보정: 제약(오경보율 15% 이하, 재현율 90% 이상) 안에서 F1 최대 조합 탐색\n",
|
|
"grid = []\n",
|
|
"for k in [2.0, 2.5, 3.0]:\n",
|
|
" te, dr = run_system(k)\n",
|
|
" for thr in [0.3, 0.4, 0.5, 0.6, 0.7]:\n",
|
|
" grid.append({\"k\": k, \"경보임계값\": thr, **summarize(set_alerts(te, thr), dr)})\n",
|
|
"g = pd.DataFrame(grid)\n",
|
|
"ok = g[(g[\"오경보율\"] <= 0.15) & (g[\"재현율\"] >= 0.9)].sort_values([\"종합정확도(F1)\", \"재현율\"], ascending=False)\n",
|
|
"best = ok.iloc[0]\n",
|
|
"print(\"\\n[3. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n",
|
|
"print(ok.head().to_string(index=False))\n",
|
|
"after_te, _ = run_system(best[\"k\"])\n",
|
|
"set_alerts(after_te, best[\"경보임계값\"])\n",
|
|
"after = summarize(after_te, drones)\n",
|
|
"after_log = aar_log(after_te, drones)\n",
|
|
"print(f\"\\n재보정값: k={best['k']}, 경보 임계값={best['경보임계값']}\")\n",
|
|
"print(\"재보정 후:\", after)\n",
|
|
"print(f\"종합정확도 변화: {before['종합정확도(F1)']:.3f} -> {after['종합정확도(F1)']:.3f} ({(after['종합정확도(F1)'] - before['종합정확도(F1)']) * 100:+.1f}%p)\")\n",
|
|
"repeat = set(log[log[\"구분\"] == \"오경보\"][\"궤적\"]) & set(after_log[after_log[\"구분\"] == \"오경보\"][\"궤적\"])\n",
|
|
"print(f\"재보정 후에도 반복된 오경보 시퀀스: {sorted(repeat) if repeat else '없음'}\")\n",
|
|
"\n",
|
|
"tuned = f\"detection_k: {best['k']}\\nalert_threshold: {best['경보임계값']}\\nconstraints: {{max_far: 0.15, min_recall: 0.9}}\"\n",
|
|
"print(\"\\n[재보정 파라미터(ops01_tuned_params.yaml 내용)]\\n\" + tuned)\n",
|
|
"\n",
|
|
"plt.figure(figsize=(6, 4))\n",
|
|
"for k in sorted(g[\"k\"].unique()):\n",
|
|
" s = g[g[\"k\"] == k]\n",
|
|
" plt.plot(s[\"오경보율\"], s[\"재현율\"], \"o-\", label=f\"k={k}\")\n",
|
|
"plt.axvline(0.15, ls=\"--\", c=\"gray\"); plt.axhline(0.9, ls=\"--\", c=\"gray\")\n",
|
|
"plt.xlabel(\"false alarm rate\"); plt.ylabel(\"drone recall\"); plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\")\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[1. 무엇이 일어났나] 초기 설정 결과: {'경보': 49, '오경보율': 0.102, '재현율': 1.0, '종합정확도(F1)': 0.946}\n",
|
|
"구분 원인 \n",
|
|
"오경보 bird 오인 4\n",
|
|
" civil 오인 1\n",
|
|
"\n",
|
|
"[2. 왜 일어났나] 오경보 기상 분포: {'clear': 2, 'night': 2, 'fog': 1}\n",
|
|
"\n",
|
|
"[3. 무엇을 바꾸나] 격자 탐색 상위 5개\n",
|
|
" k 경보임계값 경보 오경보율 재현율 종합정확도(F1)\n",
|
|
"2.5 0.5 44 0.023 0.977 0.977\n",
|
|
"2.5 0.6 44 0.023 0.977 0.977\n",
|
|
"2.0 0.5 45 0.044 0.977 0.966\n",
|
|
"2.5 0.4 45 0.044 0.977 0.966\n",
|
|
"2.0 0.4 48 0.083 1.000 0.957\n",
|
|
"\n",
|
|
"재보정값: k=2.5, 경보 임계값=0.5\n",
|
|
"재보정 후: {'경보': 44, '오경보율': 0.023, '재현율': 0.977, '종합정확도(F1)': 0.977}\n",
|
|
"종합정확도 변화: 0.946 -> 0.977 (+3.1%p)\n",
|
|
"재보정 후에도 반복된 오경보 시퀀스: ['SEQ022']\n",
|
|
"\n",
|
|
"[재보정 파라미터(ops01_tuned_params.yaml 내용)]\n",
|
|
"detection_k: 2.5\n",
|
|
"alert_threshold: 0.5\n",
|
|
"constraints: {max_far: 0.15, min_recall: 0.9}\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 10. 드론 공중위협 객체 탐지 최종 파이프라인 통합 및 대드론 방어 브리핑 패키지 완성\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"데이터 수집부터 전처리, 탐지 모델, UI, HITL, AAR 피드백까지 전체 파이프라인을 통합 빌드하고 야전 방공부대 배포 매뉴얼 및 지휘관 브리핑 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 생성부터 전처리, 탐지, 상황판, HITL, AAR 재보정값 적용까지 전체 파이프라인을 통합하고 지휘관 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **재보정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 데이터가 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/main_ops01.py, README.md, docs/ops01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"데이터 생성·검증부터 전처리, 탐지, 추적·패턴, 신뢰도 점수, HITL 승인 대기열, 상황판, AAR 재보정 파라미터 적용까지 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검 결과와 지휘관 브리핑 요약을 출력한다.\"\"\"\n",
|
|
"import hashlib\n",
|
|
"import json\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.linear_model import LogisticRegression\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)\n",
|
|
"# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"H = W = 64\n",
|
|
"PROFILE = {\"drone\": ((2, 3), (170, 225)), \"bird\": ((2, 3), (110, 150)),\n",
|
|
" \"civil\": ((4, 6), (190, 240)), \"clutter\": ((2, 4), (140, 190))}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _new_obj(cls, rng, dive=False, v=None, xy=None):\n",
|
|
" (s0, s1), (h0, h1) = PROFILE[cls]\n",
|
|
" x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))\n",
|
|
" speed = {\"drone\": 1.3, \"bird\": 0.9, \"civil\": 0.35, \"clutter\": 0.0}[cls]\n",
|
|
" vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)\n",
|
|
" return {\"cls\": cls, \"size\": int(rng.integers(s0, s1 + 1)), \"heat\": float(rng.uniform(h0, h1)),\n",
|
|
" \"x\": float(x), \"y\": float(y), \"vx\": float(vx), \"vy\": float(vy), \"dive\": dive}\n",
|
|
"\n",
|
|
"\n",
|
|
"def _noise(img, cond, rng):\n",
|
|
" f = img.astype(float)\n",
|
|
" if cond == \"night\":\n",
|
|
" f = f * 0.8 + rng.normal(0, 14, f.shape)\n",
|
|
" elif cond == \"fog\":\n",
|
|
" f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)\n",
|
|
" elif cond == \"rain\":\n",
|
|
" f = f + rng.normal(0, 8, f.shape)\n",
|
|
" for _ in range(20):\n",
|
|
" x, y0 = rng.integers(0, W), rng.integers(0, H - 8)\n",
|
|
" f[y0:y0 + 8, x] += 45\n",
|
|
" else:\n",
|
|
" f = f + rng.normal(0, 6, f.shape)\n",
|
|
" return np.clip(f, 0, 255).astype(np.uint8)\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_sequence(seq_id, cond, rng, kind=\"mixed\", n_frames=30, n_drone=None, n_bird=None):\n",
|
|
" \"\"\"시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation\"\"\"\n",
|
|
" objs = []\n",
|
|
" if kind == \"swarm\":\n",
|
|
" v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)\n",
|
|
" objs = [_new_obj(\"drone\", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]\n",
|
|
" objs.append(_new_obj(\"bird\", rng))\n",
|
|
" elif kind == \"dive\":\n",
|
|
" d = _new_obj(\"drone\", rng, dive=True)\n",
|
|
" d[\"y\"] = float(rng.uniform(4, 14))\n",
|
|
" objs = [d, _new_obj(\"bird\", rng), _new_obj(\"clutter\", rng)]\n",
|
|
" elif kind == \"saturation\":\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(n_drone)] + [_new_obj(\"bird\", rng) for _ in range(n_bird)]\n",
|
|
" else:\n",
|
|
" objs = [_new_obj(\"drone\", rng) for _ in range(int(rng.integers(1, 3)))]\n",
|
|
" objs += [_new_obj(\"bird\", rng) for _ in range(int(rng.integers(0, 3)))]\n",
|
|
" if rng.random() < 0.5:\n",
|
|
" objs.append(_new_obj(\"civil\", rng))\n",
|
|
" if rng.random() < 0.6:\n",
|
|
" objs.append(_new_obj(\"clutter\", rng))\n",
|
|
" frames, labels = np.zeros((n_frames, H, W), np.uint8), []\n",
|
|
" base = rng.normal(40, 6, (H, W))\n",
|
|
" for t in range(n_frames):\n",
|
|
" canvas = base.copy()\n",
|
|
" for i, o in enumerate(objs):\n",
|
|
" if t > 0:\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" o[\"vx\"] = float(np.clip(o[\"vx\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" o[\"vy\"] = float(np.clip(o[\"vy\"] + rng.normal(0, 0.45), -1.6, 1.6))\n",
|
|
" if o[\"dive\"] and t > 10:\n",
|
|
" o[\"vy\"] += 0.18\n",
|
|
" o[\"x\"] = float(np.clip(o[\"x\"] + o[\"vx\"], 1, W - o[\"size\"] - 1))\n",
|
|
" o[\"y\"] = float(np.clip(o[\"y\"] + o[\"vy\"], 1, H - o[\"size\"] - 1))\n",
|
|
" heat = o[\"heat\"]\n",
|
|
" if o[\"cls\"] == \"clutter\" and rng.random() < 0.3:\n",
|
|
" heat *= 0.7\n",
|
|
" if o[\"cls\"] == \"bird\":\n",
|
|
" heat *= float(rng.uniform(0.85, 1.1))\n",
|
|
" x, y, s = int(round(o[\"x\"])), int(round(o[\"y\"])), o[\"size\"]\n",
|
|
" canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)\n",
|
|
" labels.append({\"frame\": t, \"track\": f\"{seq_id}_T{i:02d}\", \"class\": o[\"cls\"], \"bbox\": [x, y, s, s],\n",
|
|
" \"pattern\": \"dive\" if o[\"dive\"] else (\"swarm\" if kind == \"swarm\" and o[\"cls\"] == \"drone\" else \"normal\")})\n",
|
|
" frames[t] = _noise(canvas, cond, rng)\n",
|
|
" return frames, labels\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=24, seed=7):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" conds = [\"clear\", \"night\", \"fog\", \"rain\"]\n",
|
|
" out = []\n",
|
|
" for i in range(n):\n",
|
|
" kind = \"swarm\" if i % 8 == 5 else (\"dive\" if i % 8 == 6 else \"mixed\")\n",
|
|
" f, lab = make_sequence(f\"SEQ{i:03d}\", conds[i % 4], rng, kind)\n",
|
|
" out.append({\"id\": f\"SEQ{i:03d}\", \"cond\": conds[i % 4], \"kind\": kind, \"frames\": f, \"labels\": lab})\n",
|
|
" return out\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리·탐지 공통 함수 ----\n",
|
|
"def preprocess(frame, cond):\n",
|
|
" f = frame.astype(float)\n",
|
|
" if cond == \"rain\": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)\n",
|
|
" f = ndimage.grey_opening(f, size=(1, 2))\n",
|
|
" lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장\n",
|
|
" return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n",
|
|
"\n",
|
|
"\n",
|
|
"def detect(frame, cond, k=2.0, min_area=3, max_area=60):\n",
|
|
" img = preprocess(frame, cond)\n",
|
|
" lab, _ = ndimage.label(img > img.mean() + k * img.std())\n",
|
|
" dets = []\n",
|
|
" for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n",
|
|
" area = int((lab[sl] == i).sum())\n",
|
|
" if min_area <= area <= max_area:\n",
|
|
" y, x = sl[0].start, sl[1].start\n",
|
|
" dets.append({\"bbox\": [x, y, sl[1].stop - x, sl[0].stop - y], \"area\": area,\n",
|
|
" \"thermal\": float(frame[sl][lab[sl] == i].max())})\n",
|
|
" return dets\n",
|
|
"\n",
|
|
"\n",
|
|
"def iou(a, b):\n",
|
|
" ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))\n",
|
|
" iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))\n",
|
|
" inter = ix * iy\n",
|
|
" return inter / (a[2] * a[3] + b[2] * b[3] - inter)\n",
|
|
"\n",
|
|
"# ---- 추적·특징·패턴 공통 함수 ----\n",
|
|
"def center(b):\n",
|
|
" return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])\n",
|
|
"\n",
|
|
"\n",
|
|
"def track(per_frame, gate=6.0, max_missed=2, min_len=5):\n",
|
|
" \"\"\"예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다.\"\"\"\n",
|
|
" active, done = [], []\n",
|
|
" for t, dets in enumerate(per_frame):\n",
|
|
" used = set()\n",
|
|
" for tr in sorted(active, key=lambda x: -len(x[\"t\"])):\n",
|
|
" last = center(tr[\"b\"][-1])\n",
|
|
" vel = last - center(tr[\"b\"][-2]) if len(tr[\"b\"]) > 1 else np.zeros(2)\n",
|
|
" pred = last + vel * (t - tr[\"t\"][-1])\n",
|
|
" cand = [(np.linalg.norm(center(d[\"bbox\"]) - pred), j) for j, d in enumerate(dets) if j not in used]\n",
|
|
" dist, j = min(cand, default=(gate, -1))\n",
|
|
" if j >= 0 and dist < gate:\n",
|
|
" used.add(j)\n",
|
|
" tr[\"t\"].append(t); tr[\"b\"].append(dets[j][\"bbox\"])\n",
|
|
" tr[\"area\"].append(dets[j][\"area\"]); tr[\"heat\"].append(dets[j][\"thermal\"])\n",
|
|
" still = []\n",
|
|
" for tr in active:\n",
|
|
" (still if t - tr[\"t\"][-1] <= max_missed else done).append(tr)\n",
|
|
" active = still\n",
|
|
" for j, d in enumerate(dets):\n",
|
|
" if j not in used:\n",
|
|
" active.append({\"t\": [t], \"b\": [d[\"bbox\"]], \"area\": [d[\"area\"]], \"heat\": [d[\"thermal\"]]})\n",
|
|
" return [tr for tr in done + active if len(tr[\"t\"]) >= min_len]\n",
|
|
"\n",
|
|
"\n",
|
|
"FEATURES = [\"mean_speed\", \"speed_std\", \"turn\", \"straight\", \"vy_accel\", \"area\", \"heat\", \"heat_std\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def features(tr):\n",
|
|
" c = np.array([center(b) for b in tr[\"b\"]]); f = np.array(tr[\"t\"], float)\n",
|
|
" v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]\n",
|
|
" sp = np.linalg.norm(v, axis=1)\n",
|
|
" k = 3 if len(c) > 4 else 1\n",
|
|
" v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]\n",
|
|
" hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2\n",
|
|
" turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]\n",
|
|
" half = len(v) // 2\n",
|
|
" return {\"mean_speed\": sp.mean(), \"speed_std\": sp.std(), \"turn\": turns.mean() if len(turns) else 0.0,\n",
|
|
" \"straight\": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),\n",
|
|
" \"vy_accel\": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,\n",
|
|
" \"area\": np.mean(tr[\"area\"]), \"heat\": np.mean(tr[\"heat\"]), \"heat_std\": np.std(tr[\"heat\"]),\n",
|
|
" \"vx\": v[:, 0].mean(), \"vy\": v[:, 1].mean(), \"x\": c[-1, 0], \"y\": c[-1, 1]}\n",
|
|
"\n",
|
|
"\n",
|
|
"def pattern(f):\n",
|
|
" if f[\"mean_speed\"] < 0.25:\n",
|
|
" return \"static\"\n",
|
|
" if f[\"vy_accel\"] > 0.06:\n",
|
|
" return \"dive\"\n",
|
|
" if f[\"turn\"] > 0.4 or f[\"straight\"] < 0.6:\n",
|
|
" return \"erratic\"\n",
|
|
" return \"steady\"\n",
|
|
"\n",
|
|
"\n",
|
|
"def swarms(trs, vel=0.2, dist=18.0, min_n=3):\n",
|
|
" \"\"\"속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다.\"\"\"\n",
|
|
" cand = [i for i, t in enumerate(trs) if t[\"pattern\"] != \"static\"]\n",
|
|
" adj = {i: set() for i in cand}\n",
|
|
" for a in cand:\n",
|
|
" for b in cand:\n",
|
|
" fa, fb = trs[a][\"f\"], trs[b][\"f\"]\n",
|
|
" if a < b and np.hypot(fa[\"vx\"] - fb[\"vx\"], fa[\"vy\"] - fb[\"vy\"]) < vel and np.hypot(fa[\"x\"] - fb[\"x\"], fa[\"y\"] - fb[\"y\"]) < dist:\n",
|
|
" adj[a].add(b); adj[b].add(a)\n",
|
|
" seen, groups = set(), []\n",
|
|
" for i in cand:\n",
|
|
" if i in seen:\n",
|
|
" continue\n",
|
|
" stack, comp = [i], []\n",
|
|
" while stack:\n",
|
|
" k = stack.pop()\n",
|
|
" if k not in seen:\n",
|
|
" seen.add(k); comp.append(k); stack += list(adj[k] - seen)\n",
|
|
" if len(comp) >= min_n:\n",
|
|
" groups.append(comp)\n",
|
|
" return groups\n",
|
|
"\n",
|
|
"\n",
|
|
"def analyze(s, k=2.0):\n",
|
|
" \"\"\"시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용).\"\"\"\n",
|
|
" per = [detect(fr, s[\"cond\"], k) if fr is not None else [] for fr in s[\"frames\"]]\n",
|
|
" trs = track(per)\n",
|
|
" for tr in trs:\n",
|
|
" tr[\"f\"] = features(tr); tr[\"pattern\"] = pattern(tr[\"f\"]); tr[\"swarm\"] = None; tr[\"seq\"] = s[\"id\"]\n",
|
|
" votes = {}\n",
|
|
" for t, b in zip(tr[\"t\"], tr[\"b\"]):\n",
|
|
" best = max(((iou(b, l[\"bbox\"]), l) for l in s[\"labels\"] if l[\"frame\"] == t), default=(0, None), key=lambda x: x[0])\n",
|
|
" key = (best[1][\"track\"], best[1][\"class\"], best[1][\"pattern\"]) if best[0] > 0.05 else (\"none\", \"noise\", \"normal\")\n",
|
|
" votes[key] = votes.get(key, 0) + 1\n",
|
|
" tr[\"gt_track\"], tr[\"gt_class\"], tr[\"gt_pattern\"] = max(votes, key=votes.get)\n",
|
|
" for g, comp in enumerate(swarms(trs)):\n",
|
|
" for i in comp:\n",
|
|
" trs[i][\"swarm\"] = g\n",
|
|
" if trs[i][\"pattern\"] in (\"steady\", \"erratic\"):\n",
|
|
" trs[i][\"pattern\"] = \"swarm\"\n",
|
|
" return trs\n",
|
|
"\n",
|
|
"CONFIG = {\"detection_k\": 2.5, \"alert_threshold\": 0.5, \"queue_capacity\": 30} # 미션 9 재보정값\n",
|
|
"\n",
|
|
"\n",
|
|
"def validate(seq):\n",
|
|
" errs = []\n",
|
|
" if seq[\"cond\"] not in (\"clear\", \"night\", \"fog\", \"rain\"):\n",
|
|
" errs.append(\"condition 허용값 아님\")\n",
|
|
" if seq[\"frames\"].ndim != 3 or seq[\"frames\"].shape[1:] != (64, 64):\n",
|
|
" errs.append(\"해상도 오류\")\n",
|
|
" for l in seq[\"labels\"]:\n",
|
|
" if l[\"class\"] not in PROFILE or len(l[\"bbox\"]) != 4:\n",
|
|
" errs.append(\"라벨 오류\"); break\n",
|
|
" return errs\n",
|
|
"\n",
|
|
"\n",
|
|
"def X_of(trs):\n",
|
|
" return np.array([[t[\"f\"][k] for k in FEATURES] for t in trs])\n",
|
|
"\n",
|
|
"\n",
|
|
"class Queue:\n",
|
|
" def __init__(self):\n",
|
|
" self.items, self.log = [], []\n",
|
|
"\n",
|
|
" def submit(self, t):\n",
|
|
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
|
|
" body = {\"event\": \"SUBMIT\", \"id\": f\"{t['seq']}-{len(self.items)}\", \"score\": round(t[\"score\"], 3)}\n",
|
|
" self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n",
|
|
" self.items.append({**body, \"grade\": t[\"grade\"], \"status\": \"PENDING\"})\n",
|
|
"\n",
|
|
"\n",
|
|
"# ---------------- 1. 학습(엔진 준비) ----------------\n",
|
|
"t_start = time.perf_counter()\n",
|
|
"train = [t for s in make_dataset(24, 7) for t in analyze(s, CONFIG[\"detection_k\"])]\n",
|
|
"engine = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=1000, random_state=0))\n",
|
|
"engine.fit(X_of(train), [t[\"gt_class\"] == \"drone\" for t in train])\n",
|
|
"\n",
|
|
"# ---------------- 2. 운용 데이터 처리 ----------------\n",
|
|
"ops = make_dataset(24, 11)\n",
|
|
"ops[3][\"cond\"] = \"snow\" # 손상 데이터 1건 주입(검증 단계에서 걸러져야 함)\n",
|
|
"queue, rejected, all_tracks, runs = Queue(), {}, [], []\n",
|
|
"for s in ops:\n",
|
|
" errs = validate(s)\n",
|
|
" if errs:\n",
|
|
" rejected[s[\"id\"]] = errs; continue\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" trs = analyze(s, CONFIG[\"detection_k\"])\n",
|
|
" if trs:\n",
|
|
" for t, p in zip(trs, engine.predict_proba(X_of(trs))[:, 1]):\n",
|
|
" t[\"score\"] = 0.0 if t[\"pattern\"] == \"static\" else float(p)\n",
|
|
" t[\"grade\"] = \"High\" if t[\"score\"] >= 0.8 else (\"Medium\" if t[\"score\"] >= 0.5 else \"Low\")\n",
|
|
" t[\"alert\"] = t[\"pattern\"] != \"static\" and t[\"score\"] >= CONFIG[\"alert_threshold\"]\n",
|
|
" if t[\"alert\"]:\n",
|
|
" queue.submit(t)\n",
|
|
" runs.append({\"seq\": s[\"id\"], \"ms_per_frame\": (time.perf_counter() - t0) * 1000 / len(s[\"frames\"]),\n",
|
|
" \"alerts\": sum(t[\"alert\"] for t in trs)})\n",
|
|
" all_tracks += trs\n",
|
|
"\n",
|
|
"# ---------------- 3. 성능 평가 ----------------\n",
|
|
"drones = {l[\"track\"] for s in ops if s[\"id\"] not in rejected for l in s[\"labels\"] if l[\"class\"] == \"drone\"}\n",
|
|
"alerts = [t for t in all_tracks if t[\"alert\"]]\n",
|
|
"far = sum(t[\"gt_class\"] != \"drone\" for t in alerts) / max(len(alerts), 1)\n",
|
|
"recall = len({t[\"gt_track\"] for t in alerts if t[\"gt_class\"] == \"drone\"}) / len(drones)\n",
|
|
"ms = np.mean([r[\"ms_per_frame\"] for r in runs])\n",
|
|
"\n",
|
|
"# ---------------- 4. 자체 점검(통합 테스트) ----------------\n",
|
|
"chk = {\n",
|
|
" \"손상 데이터 차단\": \"SEQ003\" in rejected,\n",
|
|
" \"처리 시퀀스 23개\": len(runs) == 23,\n",
|
|
" \"드론 재현율 90% 이상\": recall >= 0.9,\n",
|
|
" \"오경보율 15% 이하\": far <= 0.15,\n",
|
|
" \"자동 승인 0건\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n",
|
|
" \"경보 전량 대기열 등록\": len(queue.items) == len(alerts),\n",
|
|
" \"감사 로그 건수 일치\": len(queue.log) == len(queue.items),\n",
|
|
" \"프레임당 50ms 이내\": ms < 50,\n",
|
|
" \"등급 규칙 일관\": all((t[\"grade\"] == \"High\") == (t[\"score\"] >= 0.8) for t in all_tracks),\n",
|
|
" \"static 궤적 경보 없음\": not any(t[\"alert\"] and t[\"pattern\"] == \"static\" for t in all_tracks),\n",
|
|
"}\n",
|
|
"passed = sum(chk.values())\n",
|
|
"for k, v in chk.items():\n",
|
|
" print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n",
|
|
"print(f\"자체 점검 {passed}/{len(chk)} 통과 ({passed / len(chk):.0%}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n",
|
|
"\n",
|
|
"# ---------------- 5. 상황판 ----------------\n",
|
|
"focus = max({t[\"seq\"] for t in all_tracks}, key=lambda q: sum(t[\"alert\"] for t in all_tracks if t[\"seq\"] == q))\n",
|
|
"col = {\"High\": \"tab:red\", \"Medium\": \"tab:orange\", \"Low\": \"tab:gray\"}\n",
|
|
"fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n",
|
|
"s = next(x for x in ops if x[\"id\"] == focus)\n",
|
|
"ax[0, 0].imshow(s[\"frames\"][-1], cmap=\"gray\")\n",
|
|
"for t in [t for t in all_tracks if t[\"seq\"] == focus]:\n",
|
|
" c = np.array([center(b) for b in t[\"b\"]]); ax[0, 0].plot(c[:, 0], c[:, 1], color=col[t[\"grade\"]], lw=2)\n",
|
|
"ax[0, 0].set_title(f\"{focus} tracks (color = grade)\"); ax[0, 0].axis(\"off\")\n",
|
|
"g = pd.Series([t[\"grade\"] for t in all_tracks]).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n",
|
|
"ax[0, 1].bar(g.index, g.values, color=[col[i] for i in g.index]); ax[0, 1].set_title(\"Tracks by grade\")\n",
|
|
"ax[1, 0].bar([r[\"seq\"][-2:] for r in runs], [r[\"alerts\"] for r in runs]); ax[1, 0].set_title(\"Alerts per sequence (pending human review)\")\n",
|
|
"ax[1, 1].axis(\"off\")\n",
|
|
"ax[1, 1].text(0, 0.9, f\"Drone recall {recall:.1%}\\nFalse alarm {far:.1%}\\nQueue {len(queue.items)} pending\\n\"\n",
|
|
" f\"Latency {ms:.2f} ms/frame\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n",
|
|
"plt.suptitle(\"OPS01 integrated pipeline - training data only\"); plt.tight_layout(); plt.show()\n",
|
|
"\n",
|
|
"# ---------------- 6. 지휘관 브리핑 요약 ----------------\n",
|
|
"print(f\"\"\"\n",
|
|
"[대드론 방어 브리핑 요약 - 교육용가상자료]\n",
|
|
"1. 처리: 시퀀스 {len(ops)}개 중 {len(runs)}개 처리, 손상 데이터 {len(rejected)}건 차단({', '.join(rejected)})\n",
|
|
"2. 성능: 드론 재현율 {recall:.1%}, 오경보율 {far:.1%}, 프레임당 {ms:.2f} ms, 전체 소요 {time.perf_counter() - t_start:.1f} s\n",
|
|
"3. 경보: {len(queue.items)}건 운용자 검토 대기 (High {sum(i['grade'] == 'High' for i in queue.items)} / Medium {sum(i['grade'] == 'Medium' for i in queue.items)}), 자동 승인 0건\n",
|
|
"4. 운용 유의: 경보는 운용자 승인 후에만 상급 지휘계통으로 전파하며, 교전 여부는 교전수칙·지휘계통에 따른다.\n",
|
|
"5. 한계: 합성 64x64 영상 기준 결과로, 실제 센서·기상·배경에서는 재학습과 재검증이 필요하다.\"\"\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[PASS] 손상 데이터 차단\n",
|
|
"[PASS] 처리 시퀀스 23개\n",
|
|
"[PASS] 드론 재현율 90% 이상\n",
|
|
"[PASS] 오경보율 15% 이하\n",
|
|
"[PASS] 자동 승인 0건\n",
|
|
"[PASS] 경보 전량 대기열 등록\n",
|
|
"[PASS] 감사 로그 건수 일치\n",
|
|
"[PASS] 프레임당 50ms 이내\n",
|
|
"[PASS] 등급 규칙 일관\n",
|
|
"[PASS] static 궤적 경보 없음\n",
|
|
"자체 점검 10/10 통과 (100%), 파이프라인 실행 성공 예\n",
|
|
"\n",
|
|
"[대드론 방어 브리핑 요약 - 교육용가상자료]\n",
|
|
"1. 처리: 시퀀스 24개 중 23개 처리, 손상 데이터 1건 차단(SEQ003)\n",
|
|
"2. 성능: 드론 재현율 97.6%, 오경보율 2.4%, 프레임당 0.67 ms, 전체 소요 1.5 s\n",
|
|
"3. 경보: 42건 운용자 검토 대기 (High 32 / Medium 10), 자동 승인 0건\n",
|
|
"4. 운용 유의: 경보는 운용자 승인 후에만 상급 지휘계통으로 전파하며, 교전 여부는 교전수칙·지휘계통에 따른다.\n",
|
|
"5. 한계: 합성 64x64 영상 기준 결과로, 실제 센서·기상·배경에서는 재학습과 재검증이 필요하다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"colab": {
|
|
"provenance": []
|
|
},
|
|
"kernelspec": {
|
|
"display_name": "Python 3",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"name": "python"
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 0
|
|
} |