Files
pbl02/uploads/TRN03/TRN03_student.ipynb
T
2026-10-08 17:15:05 +09:00

3997 lines
264 KiB
Plaintext

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 🎯 [TRN03] 드론·미사일 실루엣 이미지 분류 학습앱\n",
"### Google Colab 실습 환경 (교육·훈련 / Level 2)\n",
"\n",
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/TRN03/TRN03_solution.ipynb)\n",
"\n",
"---\n",
"\n",
"## 📌 1. 프로젝트 개요 및 배경\n",
"\n",
"| 항목 | 내용 |\n",
"| :--- | :--- |\n",
"| **과정 분류** | 교육·훈련 (Level 2) |\n",
"| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n",
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
"\n",
"### 📖 프로젝트 목표\n",
"러시아-우크라이나 전쟁과 미국-이란 분쟁 사례에서는 소형 드론, 자폭 드론, 순항·탄도미사일이 함께 쓰이면서, 장병이 하늘의 비행체 형상을 보고 위협 여부를 빨리 구분하는 능력이 중요한 기본 전투력이 되었다. 본 프로젝트에서는 실제 기체 사진 대신 사각형·삼각형·원 같은 기본 도형으로 만든 교육용 합성 실루엣을 코드로 생성해, 레이블 체계 정의, 명암·윤곽선·크기 정규화 전처리, 형태 특징 기반 Baseline, 다양한 각도·원거리·흐림 조건의 신경망 분류 고도화, 유사 형상 오분류 방지 신뢰도 점수, 학습 앱 UI, 교관 해설 검토(HITL), 대량 저품질 이미지 부하 시험, AAR 증강 재학습, 최종 패키징까지 실루엣 식별 학습앱을 단계별로 구현한다. 최종 목표는 AI가 문항 출제와 참고 판정을 돕되, 불확실한 판정과 오답 해설은 반드시 교관이 확인한 뒤 장병에게 제공되는 학습 체계를 만드는 것이다.\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",
"전술 드론 및 순항/탄도미사일 실루엣 이미지 데이터셋을 구축하고 레이블을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전술 드론과 순항·탄도미사일 등 비행체 실루엣 이미지 학습 데이터의 레이블 체계와 메타데이터 스키마를 정의하고, 잘못된 데이터를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **민간 쿼드콥터형·민간 고정익형·삼각익 자폭드론형·순항미사일형·탄도미사일형·조류 6종 레이블과 위협 여부, 촬영 조건(기본·원거리·측면·야간·흐림), 센서(EO/IR), 전술 파라미터(기상, 거리 등급, 위치오차, 통신지연), 교관 검수 상태 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `data/schema/trn03_input_schema.json 및 docs/trn03_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": [
"\"\"\"전술 드론·순항/탄도미사일 등 비행체 실루엣 이미지(흑백·원거리·다각도) 학습 데이터셋의 레이블 체계와 메타데이터 스키마를 정의하고, 합성 실루엣 샘플을 만들어 검증한다.\"\"\"\n",
"import json\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 1] 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 1] 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# 1) 스키마 정의: 이미지 규격, 레이블(클래스·위협 여부), 촬영 조건, 전술 파라미터, 검수 상태\n",
"SCHEMA = {\n",
" \"fields\": {\n",
" \"image_id\": {\"type\": str, \"pattern\": r\"^SIL\\d{5}$\"},\n",
" \"class\": {\"type\": str, \"enum\": CLASSES},\n",
" \"threat\": {\"type\": bool},\n",
" \"condition\": {\"type\": str, \"enum\": list(CONDITIONS)},\n",
" \"view\": {\"type\": str, \"enum\": [\"top\", \"oblique\"]},\n",
" \"source\": {\"type\": str, \"enum\": [\"synthetic\"]},\n",
" \"reviewed_by\": {\"type\": str, \"pattern\": r\"^INS\\d{2}$\"},\n",
" \"review_status\": {\"type\": str, \"enum\": [\"draft\", \"approved\"]},\n",
" },\n",
" \"sensor\": {\"type\": {\"type\": str, \"enum\": [\"EO\", \"IR\"]}, \"resolution\": {\"type\": list, \"length\": 2}},\n",
" \"tactical\": {\n",
" \"weather\": {\"type\": str, \"enum\": [\"clear\", \"haze\", \"cloud\", \"night\", \"rain\"]},\n",
" \"range_level\": {\"type\": str, \"enum\": [\"near\", \"mid\", \"far\"]},\n",
" \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n",
" \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n",
" },\n",
"}\n",
"\n",
"\n",
"def check(name, value, rule):\n",
" if not isinstance(value, rule[\"type\"]) or (isinstance(value, bool) and rule[\"type\"] is not 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 = []\n",
" for k, rule in SCHEMA[\"fields\"].items():\n",
" errs += [f\"{k}: 필수 필드 누락\"] if k not in rec else check(k, rec[k], rule)\n",
" for g in (\"sensor\", \"tactical\"):\n",
" for k, rule in SCHEMA[g].items():\n",
" errs += [f\"{g}.{k}: 필수 필드 누락\"] if k not in rec.get(g, {}) else check(f\"{g}.{k}\", rec[g][k], rule)\n",
" px = np.asarray(rec.get(\"pixels\", []), dtype=float)\n",
" if px.shape != (IMG, IMG):\n",
" errs.append(f\"pixels: 크기 {IMG}x{IMG} 필요(입력 {px.shape})\")\n",
" elif not np.isfinite(px).all() or px.min() < 0 or px.max() > 255:\n",
" errs.append(\"pixels: 값 범위(0~255) 또는 결측 오류\")\n",
" if rec.get(\"class\") in CLASSES and rec.get(\"threat\") != (rec[\"class\"] in THREAT):\n",
" errs.append(\"threat: 클래스와 위협 여부 불일치\")\n",
" return errs\n",
"\n",
"\n",
"# 2) [교육용가상자료] 클래스별 10장, 총 60장 샘플 생성(기본 + 원거리 + 측면 + 야간 조건)\n",
"rng = np.random.default_rng(1)\n",
"conds = [\"basic\", \"far\", \"oblique\", \"night\", \"blur\"]\n",
"weather = {\"basic\": \"clear\", \"far\": \"haze\", \"oblique\": \"clear\", \"night\": \"night\", \"blur\": \"rain\"}\n",
"records = []\n",
"for cls in CLASSES:\n",
" for i in range(10):\n",
" cond = conds[i % len(conds)]\n",
" records.append({\"image_id\": f\"SIL{len(records):05d}\", \"class\": cls, \"threat\": cls in THREAT,\n",
" \"condition\": cond, \"view\": \"oblique\" if cond == \"oblique\" else \"top\", \"source\": \"synthetic\",\n",
" \"reviewed_by\": \"INS01\", \"review_status\": \"approved\",\n",
" \"sensor\": {\"type\": \"IR\" if cond == \"night\" else \"EO\", \"resolution\": [IMG, IMG]},\n",
" \"tactical\": {\"weather\": weather[cond], \"range_level\": \"far\" if cond == \"far\" else \"mid\",\n",
" \"position_error_m\": float(round(rng.uniform(5, 60), 1)), \"comm_delay_ms\": int(rng.integers(20, 300))},\n",
" \"pixels\": draw_cond(cls, cond, rng).tolist()})\n",
"\n",
"ok = sum(1 for r in records if not validate(r))\n",
"print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 (목표 오류 0건: {'달성' if ok == len(records) else '미달'})\")\n",
"\n",
"# 3) 오류 주입 시험\n",
"bad = json.loads(json.dumps(records[0]))\n",
"bad[\"image_id\"] = \"IMG-7\"\n",
"bad[\"class\"] = \"ufo\"\n",
"bad[\"threat\"] = \"yes\"\n",
"del bad[\"tactical\"][\"comm_delay_ms\"]\n",
"bad[\"pixels\"] = bad[\"pixels\"][:20]\n",
"bad2 = json.loads(json.dumps(records[25]))\n",
"bad2[\"threat\"] = False # 자폭드론형인데 비위협으로 잘못 표기\n",
"bad2[\"pixels\"][3][3] = 300\n",
"print(\"\\n[오류 주입 결과]\")\n",
"for e in validate(bad) + validate(bad2):\n",
" print(\" -\", e)\n",
"\n",
"# 4) 레이블 정의서와 필드 명세\n",
"print(\"\\n[레이블 정의(추상 도형 기준)]\")\n",
"desc = {\"civil_quad\": \"십자 팔 + 원형 회전날개 4개\", \"civil_fw\": \"직선 주익 + 꼬리날개\", \"attack_fw\": \"삼각형(델타) 날개, 꼬리 없음\",\n",
" \"cruise\": \"가늘고 긴 동체 + 작은 중간 날개\", \"ballistic\": \"굵은 원통 동체 + 뾰족한 머리 + 꼬리 핀\", \"bird\": \"갈매기형 굽은 날개\"}\n",
"print(pd.DataFrame({\"레이블\": CLASSES, \"한글명\": [KOR[c] for c in CLASSES], \"위협\": [\"예\" if c in THREAT else \"아니오\" for c in CLASSES],\n",
" \"식별 핵심 특징\": [desc[c] for c in CLASSES]}).to_string(index=False))\n",
"rows = [(g, k, (r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"),\n",
" \", \".join(f\"{x}={y}\" for x, y in r.items() if x != \"type\")[:60]) for g in (\"fields\", \"sensor\", \"tactical\") for k, r in SCHEMA[g].items()]\n",
"print(\"\\n[필드 명세 요약] 필드\", len(rows) + 1, \"개(pixels 포함), 정의 완성도 100%\")\n",
"print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n",
"\n",
"# 5) 샘플 실루엣 확인\n",
"fig, ax = plt.subplots(len(CLASSES), len(conds), figsize=(8, 9))\n",
"for i, cls in enumerate(CLASSES):\n",
" for j, cond in enumerate(conds):\n",
" r = next(x for x in records if x[\"class\"] == cls and x[\"condition\"] == cond)\n",
" ax[i, j].imshow(r[\"pixels\"], cmap=\"gray\", vmin=0, vmax=255); ax[i, j].axis(\"off\")\n",
" ax[i, j].set_title(f\"{cls}/{cond}\", fontsize=7)\n",
"plt.suptitle(\"Synthetic abstract silhouettes (training data only)\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"정상 샘플 60건 중 검증 통과 60건, 오류 0건 (목표 오류 0건: 달성)\n",
"\n",
"[오류 주입 결과]\n",
" - image_id: 형식 불일치 'IMG-7'\n",
" - class: 허용되지 않은 값 'ufo'\n",
" - threat: 자료형 오류(str)\n",
" - tactical.comm_delay_ms: 필수 필드 누락\n",
" - pixels: 크기 32x32 필요(입력 (20, 32))\n",
" - pixels: 값 범위(0~255) 또는 결측 오류\n",
" - threat: 클래스와 위협 여부 불일치\n",
"\n",
"[레이블 정의(추상 도형 기준)]\n",
" 레이블 한글명 위협 식별 핵심 특징\n",
"civil_quad 민간 쿼드콥터형 아니오 십자 팔 + 원형 회전날개 4개\n",
" civil_fw 민간 고정익형 아니오 직선 주익 + 꼬리날개\n",
" attack_fw 삼각익 자폭드론형 예 삼각형(델타) 날개, 꼬리 없음\n",
" cruise 순항미사일형 예 가늘고 긴 동체 + 작은 중간 날개\n",
" ballistic 탄도미사일형 예 굵은 원통 동체 + 뾰족한 머리 + 꼬리 핀\n",
" bird 조류 아니오 갈매기형 굽은 날개\n",
"\n",
"[필드 명세 요약] 필드 15 개(pixels 포함), 정의 완성도 100%\n",
" 그룹 필드 자료형 규칙\n",
" fields image_id str pattern=^SIL\\d{5}$\n",
" fields class str enum=['civil_quad', 'civil_fw', 'attack_fw', 'cruise', 'ball\n",
" fields threat bool \n",
" fields condition str enum=['basic', 'offcenter', 'far', 'blur', 'lowcon', 'clutte\n",
" fields view str enum=['top', 'oblique']\n",
" fields source str enum=['synthetic']\n",
" fields reviewed_by str pattern=^INS\\d{2}$\n",
" fields review_status str enum=['draft', 'approved']\n",
" sensor type str enum=['EO', 'IR']\n",
" sensor resolution list length=2\n",
"tactical weather str enum=['clear', 'haze', 'cloud', 'night', 'rain']\n",
"tactical range_level str enum=['near', 'mid', 'far']\n",
"tactical position_error_m number min=0, max=500\n",
"tactical comm_delay_ms number min=0, max=10000\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 02. 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현\n",
"**소요시간**: 약 2.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **흑백·원거리·야간 등 조건이 다른 실루엣 이미지를 같은 기준으로 비교할 수 있도록 명암 대비 강화, 윤곽선 검출, 크기 정규화 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **결측 픽셀 보정, 야간 열상 극성 자동 반전, 잡음 추정값에 따른 중간값 필터 적용(조건별 임계값), 명암 대비 강화, Sobel 윤곽선 검출, 주축 방향·크기 정규화(24x24)를 배치 단위로 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/preprocess/trn03_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": [
"\"\"\"실루엣 이미지의 결측 보정, 야간 극성 보정, 잡음 제거, 명암 대비 강화, 윤곽선(Edge) 검출, 방향·크기 정규화를 일괄 처리하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·전처리 전후 식별 정확도를 확인한다.\"\"\"\n",
"import time\n",
"import warnings\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"from sklearn.neighbors import NearestCentroid\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 2] 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 2] 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"warnings.filterwarnings(\"ignore\")\n",
"\n",
"# 1) 처리 속도: 2,400장 일괄 처리\n",
"X, y, c = make_dataset(400, 1, tuple(CONDITIONS))\n",
"preprocess(X[:50]) # 준비 실행\n",
"t0 = time.perf_counter()\n",
"P = preprocess(X)\n",
"dt = time.perf_counter() - t0\n",
"err = int((~np.isfinite(P[\"norm\"])).sum())\n",
"print(f\"전처리 {len(X):,}장, {len(X) / dt:,.0f}장/초 (목표 1,000장/초 이상: {'달성' if len(X) / dt >= 1000 else '미달'}), 출력 오류 {err}건\")\n",
"\n",
"# 2) 단위 테스트 5종\n",
"tests = {}\n",
"tests[\"T1 출력 크기 24x24 고정\"] = P[\"norm\"].shape[1:] == (OUT, OUT)\n",
"tests[\"T2 값 범위 0~1\"] = bool(P[\"norm\"].min() >= 0 and P[\"norm\"].max() <= 1)\n",
"rng = np.random.default_rng(2)\n",
"day = draw(\"attack_fw\", rng, angle=30, scale=.8, fore=1.0, noise=0)\n",
"rng = np.random.default_rng(2)\n",
"night = draw(\"attack_fw\", rng, angle=30, scale=.8, fore=1.0, noise=0, invert=True)\n",
"pd_, pn = preprocess([day, night])[\"norm\"]\n",
"tests[\"T3 야간 반전 영상 극성 자동 보정\"] = bool(np.abs(pd_ - pn).mean() < 0.05)\n",
"holes = day.astype(float); holes[rng.random(holes.shape) < 0.1] = np.nan\n",
"ph = preprocess([holes])\n",
"tests[\"T4 결측 픽셀 보정율 100%\"] = bool(ph[\"missing\"][0] > 0 and np.isfinite(ph[\"norm\"]).all())\n",
"blank = np.full((IMG, IMG), 200.0)\n",
"pb = preprocess([blank])\n",
"tests[\"T5 빈 영상은 예외 없이 '판별 불가' 표시\"] = bool(not pb[\"ok\"][0])\n",
"for k, v in tests.items():\n",
" print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n",
"print(f\"결측 픽셀 {ph['missing'][0]}개 보정, 단위 테스트 {sum(tests.values())}/5 통과\")\n",
"\n",
"# 3) 전처리 전후 식별 정확도(최근접 중심 분류기, 기본 조건으로 학습)\n",
"Xtr, ytr, _ = make_dataset(60, 3)\n",
"raw = lambda imgs: (np.asarray(imgs, float) / 255).reshape(len(imgs), -1)\n",
"nc_raw = NearestCentroid().fit(raw(Xtr), ytr)\n",
"nc_pre = NearestCentroid().fit(preprocess(Xtr)[\"norm\"].reshape(len(Xtr), -1), ytr)\n",
"rows = []\n",
"for cond in [\"basic\", \"offcenter\", \"night\", \"lowcon\", \"noise\", \"far\"]:\n",
" Xt, yt, _ = make_dataset(30, 4, (cond,))\n",
" pt = preprocess(Xt)\n",
" rows.append({\"조건\": cond, \"전처리 전\": (nc_raw.predict(raw(Xt)) == yt).mean(),\n",
" \"전처리 후\": (nc_pre.predict(pt[\"norm\"].reshape(len(Xt), -1)) == yt).mean(),\n",
" \"잡음필터 적용률\": pt[\"denoised\"].mean(), \"극성반전 비율\": pt[\"inverted\"].mean()})\n",
"print(\"\\n[전처리 전후 식별 정확도]\")\n",
"print(pd.DataFrame(rows).round(3).to_string(index=False))\n",
"\n",
"# 4) 조건별 잡음 임계값 동적 조정 효과: 임계값을 낮춰 모든 영상에 필터를 쓰면 원거리 소형 표적이 손상된다\n",
"Xf, yf, _ = make_dataset(30, 4, (\"far\",))\n",
"for t in (NOISE_T, 0.0):\n",
" acc = (nc_pre.predict(preprocess(Xf, noise_t=t)[\"norm\"].reshape(len(Xf), -1)) == yf).mean()\n",
" print(f\"원거리 조건, 잡음 임계값 {t:>4}: 정확도 {acc:.3f}\")\n",
"\n",
"# 5) 전처리 단계 시각화\n",
"idx = [np.flatnonzero(c == k)[0] for k in [\"basic\", \"night\", \"noise\", \"lowcon\", \"far\"]]\n",
"fig, ax = plt.subplots(4, len(idx), figsize=(10, 8))\n",
"for j, i in enumerate(idx):\n",
" for r, (img, name) in enumerate([(X[i], \"raw\"), (P[\"enh\"][i], \"contrast\"), (P[\"edge\"][i], \"edge\"), (P[\"norm\"][i], \"normalized 24x24\")]):\n",
" ax[r, j].imshow(img, cmap=\"gray\"); ax[r, j].axis(\"off\"); ax[r, j].set_title(f\"{c[i]} {name}\", fontsize=8)\n",
"plt.suptitle(\"Silhouette preprocessing steps (synthetic)\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"전처리 2,400장, 2,056장/초 (목표 1,000장/초 이상: 달성), 출력 오류 0건\n",
"[PASS] T1 출력 크기 24x24 고정\n",
"[PASS] T2 값 범위 0~1\n",
"[PASS] T3 야간 반전 영상 극성 자동 보정\n",
"[PASS] T4 결측 픽셀 보정율 100%\n",
"[PASS] T5 빈 영상은 예외 없이 '판별 불가' 표시\n",
"결측 픽셀 109개 보정, 단위 테스트 5/5 통과\n",
"\n",
"[전처리 전후 식별 정확도]\n",
" 조건 전처리 전 전처리 후 잡음필터 적용률 극성반전 비율\n",
" basic 0.539 0.978 0.0 0.0\n",
"offcenter 0.344 0.939 0.0 0.0\n",
" night 0.167 0.983 0.0 1.0\n",
" lowcon 0.322 0.978 0.0 0.0\n",
" noise 0.544 0.911 1.0 0.0\n",
" far 0.194 0.722 0.0 0.0\n",
"원거리 조건, 잡음 임계값 12.0: 정확도 0.722\n",
"원거리 조건, 잡음 임계값 0.0: 정확도 0.417\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 03. 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **길쭉함·채움률·볼록도·폭 분포 같은 기본 형태 특징으로 비행체 실루엣을 1차 분류하는 Baseline 모델을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **분할 마스크에서 회전에 무관한 형태 특징 8종을 계산하고 결정나무로 분류 규칙을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/models/trn03_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": [
"\"\"\"형태 특징 8종(길쭉함·채움률·볼록도·폭 분포 등)으로 비행체 실루엣을 1차 분류하는 결정나무 Baseline을 구현하고, 판단 규칙을 그대로 출력해 투명성을 확인하며 기본 데이터셋 정확도·처리 지연·조건별 한계를 측정한다.\"\"\"\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 3] 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 3] 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"# 1) 학습·검증 데이터(기본 조건) 분리\n",
"Xtr, ytr, _ = make_dataset(60, 1)\n",
"Xte, yte, _ = make_dataset(40, 2)\n",
"_, Ftr, _ = feature_matrix(Xtr)\n",
"_, Fte, _ = feature_matrix(Xte)\n",
"\n",
"# 2) 휴리스틱 1차 튜닝: 규칙 깊이(설명 가능한 범위 2~5)별 검증 정확도\n",
"print(\"규칙 깊이 검증 정확도\")\n",
"for d in [2, 3, 4, 5]:\n",
" m = DecisionTreeClassifier(max_depth=d, random_state=0).fit(Ftr, ytr)\n",
" print(f\"{d:>6} {(m.predict(Fte) == yte).mean():.3f}\")\n",
"model = DecisionTreeClassifier(max_depth=4, random_state=0).fit(Ftr, ytr)\n",
"\n",
"# 3) 기본 데이터셋 정확도와 처리 지연\n",
"t0 = time.perf_counter()\n",
"_, F1, _ = feature_matrix(Xte)\n",
"pred = model.predict(F1)\n",
"ms = (time.perf_counter() - t0) * 1000 / len(Xte)\n",
"acc = (pred == yte).mean()\n",
"print(f\"\\n기본 데이터셋 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), 1장당 판단 지연 {ms:.2f} ms\")\n",
"print(pd.crosstab(pd.Series(yte, name=\"정답\"), pd.Series(pred, name=\"예측\")))\n",
"\n",
"# 4) 판단 규칙(투명성): 교관이 그대로 읽을 수 있는 if-then 규칙\n",
"print(\"\\n[판단 규칙 일부]\")\n",
"rules = export_text(model, feature_names=FEATS, decimals=2).splitlines()\n",
"print(\"\\n\".join(rules[:12]) + f\"\\n... (총 {len(rules)}줄)\")\n",
"imp = pd.Series(model.feature_importances_, index=FEATS).sort_values(ascending=False)\n",
"print(\"중요 특징 상위 3:\", \", \".join(f\"{k}({v:.2f})\" for k, v in imp.head(3).items()))\n",
"\n",
"# 5) 한계: 기본 조건에서 만든 규칙을 어려운 조건에 적용\n",
"rows = []\n",
"for cond in [\"far\", \"blur\", \"oblique\", \"occlude\", \"combo\"]:\n",
" Xh, yh, _ = make_dataset(20, 3, (cond,))\n",
" rows.append({\"조건\": cond, \"정확도\": round((model.predict(feature_matrix(Xh)[1]) == yh).mean(), 3)})\n",
"print(\"\\n[어려운 조건 정확도(Baseline 한계)]\", \", \".join(f\"{r['조건']} {r['정확도']}\" for r in rows))\n",
"print(\"기본 실행 로그: 학습 360장, 검증 240장, 규칙 깊이 4, 오류 0건\")\n",
"\n",
"plt.figure(figsize=(14, 6))\n",
"plot_tree(model, feature_names=FEATS, class_names=list(model.classes_), filled=True, fontsize=7, max_depth=3)\n",
"plt.title(\"Baseline decision rules (synthetic silhouettes)\")\n",
"plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"규칙 깊이 검증 정확도\n",
" 2 0.654\n",
" 3 0.812\n",
" 4 0.946\n",
" 5 0.979\n",
"\n",
"기본 데이터셋 정확도 94.6% (목표 75% 이상: 달성), 1장당 판단 지연 1.32 ms\n",
"예측 attack_fw ballistic bird civil_fw civil_quad cruise\n",
"정답 \n",
"attack_fw 40 0 0 0 0 0\n",
"ballistic 0 39 1 0 0 0\n",
"bird 0 2 38 0 0 0\n",
"civil_fw 0 0 0 40 0 0\n",
"civil_quad 0 0 0 8 32 0\n",
"cruise 0 0 2 0 0 38\n",
"\n",
"[판단 규칙 일부]\n",
"|--- w_max <= 0.43\n",
"| |--- end_gap <= 0.10\n",
"| | |--- class: bird\n",
"| |--- end_gap > 0.10\n",
"| | |--- class: ballistic\n",
"|--- w_max > 0.43\n",
"| |--- fill <= 0.44\n",
"| | |--- w_max <= 0.64\n",
"| | | |--- class: cruise\n",
"| | |--- w_max > 0.64\n",
"| | | |--- fill <= 0.29\n",
"| | | | |--- class: civil_fw\n",
"... (총 16줄)\n",
"중요 특징 상위 3: w_max(0.41), fill(0.38), end_gap(0.21)\n",
"\n",
"[어려운 조건 정확도(Baseline 한계)] far 0.442, blur 0.45, oblique 0.292, occlude 0.533, combo 0.367\n",
"기본 실행 로그: 학습 360장, 검증 240장, 규칙 깊이 4, 오류 0건\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 04. 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다양한 각도·원거리·배경 잡음 환경에서도 실루엣을 정밀하게 분류하도록 소형 신경망 모델로 고도화하고 복합 위협 시나리오로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **정규화 이미지 576픽셀과 형태 특징 8종을 입력으로 하는 다층 퍼셉트론을 모든 촬영 조건을 섞은 증강 데이터로 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/models/trn03_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": [
"\"\"\"다양한 각도·원거리·흐림·잡음·구름·가림·야간 조건을 섞어 학습한 다층 퍼셉트론(소형 신경망) 분류기로 실루엣 분류를 고도화하고, 복합 위협 시나리오 10종에서 위협 재현율·처리 지연·오경보 유발 패턴을 검증한다.\"\"\"\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"from sklearn.neural_network import MLPClassifier\n",
"from sklearn.preprocessing import StandardScaler\n",
"from sklearn.tree import DecisionTreeClassifier\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 4] 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 4] 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n",
"def to_input(F, N, scaler):\n",
" return np.c_[N, scaler.transform(F) * 0.3]\n",
"\n",
"\n",
"def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n",
" X, y, _ = make_dataset(n_per_class, seed, conds)\n",
" P, F, N = feature_matrix(X)\n",
" scaler = StandardScaler().fit(F)\n",
" model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n",
" model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n",
" return model, scaler\n",
"\n",
"\n",
"def predict(model, scaler, images):\n",
" \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n",
" P, F, N = feature_matrix(images)\n",
" return P, model.predict_proba(to_input(F, N, scaler))\n",
"\n",
"# 1) 학습: 모든 촬영 조건을 섞은 증강 데이터(클래스당 200장)\n",
"t0 = time.perf_counter()\n",
"model, scaler = train_model(200, seed=5, hidden=(96,))\n",
"print(f\"신경망 학습 완료 ({time.perf_counter() - t0:.1f} s, 입력 584차원 = 정규화 이미지 576 + 형태 특징 8)\")\n",
"Xb, yb, _ = make_dataset(60, 1)\n",
"tree = DecisionTreeClassifier(max_depth=4, random_state=0).fit(feature_matrix(Xb)[1], yb) # 미션 3 Baseline\n",
"\n",
"# 2) 복합 위협 시나리오 10종 시험(시나리오당 클래스별 15장)\n",
"SCEN = [k for k in CONDITIONS if k != \"basic\"]\n",
"rows, all_true, all_pred = [], [], []\n",
"for i, cond in enumerate(SCEN):\n",
" X, y, _ = make_dataset(15, 100 + i, (cond,))\n",
" t0 = time.perf_counter()\n",
" P, prob = predict(model, scaler, X)\n",
" ms = (time.perf_counter() - t0) * 1000 / len(X)\n",
" pred = np.array(CLASSES)[prob.argmax(1)]\n",
" thr = np.isin(y, list(THREAT))\n",
" rec = np.isin(pred[thr], list(THREAT)).mean()\n",
" rows.append({\"시나리오\": cond, \"Baseline 정확도\": (tree.predict(feature_matrix(X)[1]) == y).mean(), \"신경망 정확도\": (pred == y).mean(),\n",
" \"위협 재현율\": rec, \"장당 ms\": ms, \"통과\": \"PASS\" if rec >= 0.85 and ms <= 200 else \"FAIL\"})\n",
" all_true += list(y); all_pred += list(pred)\n",
"df = pd.DataFrame(rows)\n",
"print(df.round(3).to_string(index=False))\n",
"yt, yp = np.array(all_true), np.array(all_pred)\n",
"tr = np.isin(yt, list(THREAT))\n",
"recall = np.isin(yp[tr], list(THREAT)).mean()\n",
"print(f\"\\n복합 위협 재현율 {recall:.1%} (목표 85% 이상: {'달성' if recall >= 0.85 else '미달'}), 최대 처리 지연 {df['장당 ms'].max():.2f} ms (목표 200 ms 이하)\")\n",
"print(f\"시나리오 통과 {int((df['통과'] == 'PASS').sum())}/10\")\n",
"\n",
"# 3) 오경보 유발 패턴: 비위협(민간기·조류)을 위협으로 판정한 경우\n",
"fa = pd.Series([f\"{t} -> {p}\" for t, p in zip(yt, yp) if t not in THREAT and p in THREAT])\n",
"print(\"\\n[오경보 유발 패턴 상위]\", fa.value_counts().head(3).to_dict())\n",
"miss = pd.Series([f\"{t} -> {p}\" for t, p in zip(yt, yp) if t in THREAT and p not in THREAT])\n",
"print(\"[위협 미탐 패턴 상위]\", miss.value_counts().head(3).to_dict())\n",
"print(\"특징 공간 재설계 방향: 원거리·결합 조건에서 날개 끝 형상이 뭉개지므로 폭 분포(end_gap, w_max) 외에 윤곽선 곡률 특징 추가를 검토한다.\")\n",
"\n",
"plt.figure(figsize=(9, 4))\n",
"x = np.arange(len(df))\n",
"plt.bar(x - 0.2, df[\"Baseline 정확도\"], 0.4, label=\"baseline tree\")\n",
"plt.bar(x + 0.2, df[\"신경망 정확도\"], 0.4, label=\"MLP (augmented)\")\n",
"plt.axhline(0.85, ls=\"--\", c=\"gray\")\n",
"plt.xticks(x, df[\"시나리오\"], rotation=30); plt.ylabel(\"accuracy\"); plt.legend()\n",
"plt.title(\"Accuracy by complex scenario (synthetic)\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"신경망 학습 완료 (3.3 s, 입력 584차원 = 정규화 이미지 576 + 형태 특징 8)\n",
" 시나리오 Baseline 정확도 신경망 정확도 위협 재현율 장당 ms 통과\n",
"offcenter 0.767 1.000 1.000 1.239 PASS\n",
" far 0.467 0.922 0.911 1.131 PASS\n",
" blur 0.400 1.000 1.000 1.201 PASS\n",
" lowcon 0.900 1.000 1.000 1.735 PASS\n",
" clutter 0.556 0.700 0.711 1.153 FAIL\n",
" occlude 0.556 0.789 0.844 1.234 FAIL\n",
" oblique 0.333 0.978 1.000 1.544 PASS\n",
" night 0.967 1.000 1.000 1.207 PASS\n",
" noise 0.778 0.956 1.000 1.178 PASS\n",
" combo 0.311 0.378 0.600 1.319 FAIL\n",
"\n",
"복합 위협 재현율 90.7% (목표 85% 이상: 달성), 최대 처리 지연 1.73 ms (목표 200 ms 이하)\n",
"시나리오 통과 7/10\n",
"\n",
"[오경보 유발 패턴 상위] {'bird -> cruise': 9, 'civil_fw -> cruise': 7, 'civil_quad -> cruise': 6}\n",
"[위협 미탐 패턴 상위] {'cruise -> bird': 14, 'ballistic -> bird': 11, 'ballistic -> civil_fw': 8}\n",
"특징 공간 재설계 방향: 원거리·결합 조건에서 날개 끝 형상이 뭉개지므로 폭 분포(end_gap, w_max) 외에 윤곽선 곡률 특징 추가를 검토한다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 05. 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"유사 형상 비행체 오분류 방지 신뢰도 점수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 막기 위해 신뢰도 점수와 등급을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **분류 확률, 1·2위 확률 차이, 영상 품질(대비·크기·잡음)을 가중 결합해 신뢰도 점수를 만들고 High·Medium·Low 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/verification/trn03_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": [
"\"\"\"민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 줄이기 위해, 분류 확률·1/2위 확률 차이·영상 품질을 결합한 신뢰도 점수와 등급(High/Medium/Low)을 산출하고, 오경보율(FAR) 감축·점수 일관성·ROC-AUC·임계값 민감도를 검증한다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"from sklearn.metrics import roc_auc_score, roc_curve\n",
"from sklearn.neural_network import MLPClassifier\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 5] 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 5] 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n",
"def to_input(F, N, scaler):\n",
" return np.c_[N, scaler.transform(F) * 0.3]\n",
"\n",
"\n",
"def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n",
" X, y, _ = make_dataset(n_per_class, seed, conds)\n",
" P, F, N = feature_matrix(X)\n",
" scaler = StandardScaler().fit(F)\n",
" model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n",
" model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n",
" return model, scaler\n",
"\n",
"\n",
"def predict(model, scaler, images):\n",
" \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n",
" P, F, N = feature_matrix(images)\n",
" return P, model.predict_proba(to_input(F, N, scaler))\n",
"\n",
"WEIGHTS = {\"p_max\": 0.6, \"margin\": 0.4} # 신뢰도 가중치 매트릭스(확률 대 확률 차이)\n",
"HIGH, MEDIUM = 0.8, 0.5\n",
"\n",
"\n",
"def quality(P):\n",
" \"\"\"영상 품질 0.5~1: 대비가 낮거나, 표적이 작거나, 잡음이 크면 낮아진다.\"\"\"\n",
" size = P[\"mask\"].sum(axis=(1, 2))\n",
" q = np.clip(P[\"contrast\"] / 120, 0, 1) * 0.4 + np.clip(size / 60, 0, 1) * 0.4 + np.clip(1 - P[\"noise\"] / 40, 0, 1) * 0.2\n",
" return 0.5 + 0.5 * q\n",
"\n",
"\n",
"def confidence(P, prob):\n",
" s = np.sort(prob, axis=1)\n",
" score = (WEIGHTS[\"p_max\"] * s[:, -1] + WEIGHTS[\"margin\"] * (s[:, -1] - s[:, -2])) * quality(P)\n",
" grade = np.where(score >= HIGH, \"High\", np.where(score >= MEDIUM, \"Medium\", \"Low\"))\n",
" return score, grade\n",
"\n",
"\n",
"def report(name, alert, y):\n",
" true_thr = np.isin(y, list(THREAT))\n",
" far = (alert & ~true_thr).sum() / max(alert.sum(), 1)\n",
" return {\"방식\": name, \"위협 경보\": int(alert.sum()), \"오경보\": int((alert & ~true_thr).sum()), \"오경보율\": round(far, 3),\n",
" \"위협 재현율\": round((alert & true_thr).sum() / true_thr.sum(), 3)}\n",
"\n",
"\n",
"model, scaler = train_model(200, seed=5, hidden=(96,))\n",
"X, y, cond = make_dataset(110, 21, tuple(CONDITIONS)) # 검증용 660장(학습에 쓰지 않음)\n",
"P, prob = predict(model, scaler, X)\n",
"pred = np.array(CLASSES)[prob.argmax(1)]\n",
"score, grade = confidence(P, prob)\n",
"is_thr_pred = np.isin(pred, list(THREAT))\n",
"\n",
"before = report(\"기존(최고 확률 클래스로 단정)\", is_thr_pred, y)\n",
"after = report(\"신뢰도 엔진(Low는 교관 확인)\", is_thr_pred & (grade != \"Low\"), y)\n",
"print(pd.DataFrame([before, 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 '미달'}), 교관 확인으로 넘긴 위협 판정 {int((is_thr_pred & (grade == 'Low')).sum())}건\")\n",
"\n",
"# 일관성: 같은 이미지에 미세 잡음(밝기 ±1)을 더해 다시 계산했을 때 같은 등급이 나오는 비율\n",
"rng = np.random.default_rng(0)\n",
"P2, prob2 = predict(model, scaler, np.clip(X + rng.integers(-1, 2, X.shape), 0, 255))\n",
"cons = (confidence(P2, prob2)[1] == grade).mean()\n",
"print(f\"신뢰도 점수 산출 일관성 {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n",
"\n",
"p_threat = prob[:, [CLASSES.index(c) for c in THREAT]].sum(1)\n",
"auc = roc_auc_score(np.isin(y, list(THREAT)), p_threat)\n",
"print(f\"위협 판별 ROC-AUC {auc:.3f}\")\n",
"\n",
"print(\"\\n[임계값 민감도: Medium 기준값별]\")\n",
"for m in [0.3, 0.4, 0.5, 0.6, 0.7]:\n",
" r = report(f\"Medium>={m}\", is_thr_pred & (score >= m), y)\n",
" print(f\"기준 {m}: 오경보율 {r['오경보율']:.3f}, 위협 재현율 {r['위협 재현율']:.3f}\")\n",
"\n",
"print(\"\\n[등급 분포: 실제 클래스별]\")\n",
"print(pd.crosstab(pd.Series(y, name=\"실제\"), pd.Series(grade, name=\"등급\")))\n",
"pair = (y == \"civil_fw\") | (y == \"attack_fw\") | (y == \"civil_quad\")\n",
"print(f\"민간 드론 vs 자폭 드론 쌍 정확도 {(pred[pair] == y[pair]).mean():.1%}, 평균 신뢰도 {score[pair].mean():.2f}\")\n",
"print(\"등급은 교육용 판단 보조이며, Low 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
"fpr, tpr, _ = roc_curve(np.isin(y, list(THREAT)), p_threat)\n",
"ax[0].plot(fpr, tpr); ax[0].plot([0, 1], [0, 1], \"k--\"); ax[0].set_title(f\"Threat ROC (AUC={auc:.3f})\")\n",
"ax[0].set_xlabel(\"false positive rate\"); ax[0].set_ylabel(\"true positive rate\")\n",
"for g, col in [(\"High\", \"tab:green\"), (\"Medium\", \"tab:orange\"), (\"Low\", \"tab:red\")]:\n",
" ax[1].hist(score[grade == g], bins=15, range=(0, 1), color=col, alpha=0.7, label=g)\n",
"ax[1].set_title(\"Confidence score (uncertainty view)\"); ax[1].set_xlabel(\"score\"); ax[1].legend()\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
" 방식 위협 경보 오경보 오경보율 위협 재현율\n",
" 기존(최고 확률 클래스로 단정) 334 29 0.087 0.924\n",
"신뢰도 엔진(Low는 교관 확인) 281 5 0.018 0.836\n",
"\n",
"오경보율 감축 79.3% (목표 40% 이상: 달성), 교관 확인으로 넘긴 위협 판정 53건\n",
"신뢰도 점수 산출 일관성 97.3% (목표 90% 이상: 달성)\n",
"위협 판별 ROC-AUC 0.983\n",
"\n",
"[임계값 민감도: Medium 기준값별]\n",
"기준 0.3: 오경보율 0.054, 위협 재현율 0.894\n",
"기준 0.4: 오경보율 0.024, 위협 재현율 0.867\n",
"기준 0.5: 오경보율 0.018, 위협 재현율 0.836\n",
"기준 0.6: 오경보율 0.011, 위협 재현율 0.809\n",
"기준 0.7: 오경보율 0.012, 위협 재현율 0.779\n",
"\n",
"[등급 분포: 실제 클래스별]\n",
"등급 High Low Medium\n",
"실제 \n",
"attack_fw 99 5 6\n",
"ballistic 72 18 20\n",
"bird 48 29 33\n",
"civil_fw 73 18 19\n",
"civil_quad 90 13 7\n",
"cruise 66 27 17\n",
"민간 드론 vs 자폭 드론 쌍 정확도 91.2%, 평균 신뢰도 0.86\n",
"등급은 교육용 판단 보조이며, Low 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 06. 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장병이 실루엣을 보고 비행체를 고르는 모바일·웹 대화형 식별 학습 앱 UI 프로토타입을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **문항 출제(정답과 혼동하기 쉬운 보기 포함), 응답 채점, 즉시 피드백, 응답 후 AI 참고 판정 표시, 진행률·점수 표시 기능을 구현하고 가상 학습자 응답으로 12문항을 모의 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/ui/trn03_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": [
"\"\"\"장병이 실루엣을 보고 비행체를 고르는 대화형 식별 학습 앱 UI 프로토타입을 구현한다. 문제 출제·응답 처리·즉시 피드백·AI 참고 판정·진행률 표시를 모의 실행해 화면 갱신 주기와 응답 처리시간을 측정하고, 야간 모드 학습 화면을 그린다.\"\"\"\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"from sklearn.neural_network import MLPClassifier\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 6] 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 6] 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n",
"def to_input(F, N, scaler):\n",
" return np.c_[N, scaler.transform(F) * 0.3]\n",
"\n",
"\n",
"def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n",
" X, y, _ = make_dataset(n_per_class, seed, conds)\n",
" P, F, N = feature_matrix(X)\n",
" scaler = StandardScaler().fit(F)\n",
" model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n",
" model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n",
" return model, scaler\n",
"\n",
"\n",
"def predict(model, scaler, images):\n",
" \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n",
" P, F, N = feature_matrix(images)\n",
" return P, model.predict_proba(to_input(F, N, scaler))\n",
"\n",
"N_OPTIONS = 4\n",
"CONFUSE = {\"civil_fw\": \"attack_fw\", \"attack_fw\": \"civil_fw\", \"cruise\": \"ballistic\", \"ballistic\": \"cruise\", \"bird\": \"civil_fw\", \"civil_quad\": \"civil_fw\"}\n",
"\n",
"\n",
"class QuizApp:\n",
" \"\"\"학습 앱 상태: 문제 은행, 현재 문제, 점수, 응답 이력. 화면 1장 = render() 결과 문자열.\"\"\"\n",
"\n",
" def __init__(self, model, scaler, rng, n_questions=12):\n",
" self.model, self.scaler, self.rng = model, scaler, rng\n",
" conds = [\"basic\", \"far\", \"oblique\", \"night\", \"blur\", \"clutter\"]\n",
" self.bank = [(c, conds[i % len(conds)]) for i, c in enumerate(rng.permutation(CLASSES * 2))][:n_questions]\n",
" self.i, self.history = 0, []\n",
" self.next_question()\n",
"\n",
" def next_question(self):\n",
" cls, cond = self.bank[self.i]\n",
" self.img, self.answer, self.cond = draw_cond(cls, cond, self.rng), cls, cond\n",
" others = [c for c in CLASSES if c not in (cls, CONFUSE[cls])]\n",
" self.options = list(self.rng.permutation([cls, CONFUSE[cls]] + list(self.rng.choice(others, N_OPTIONS - 2, replace=False))))\n",
" self.feedback = None\n",
"\n",
" def submit(self, choice_no):\n",
" \"\"\"응답 처리: 채점 -> AI 참고 판정(신뢰도) -> 피드백 문구. 처리시간(ms)을 함께 기록한다.\"\"\"\n",
" t0 = time.perf_counter()\n",
" choice = self.options[choice_no - 1]\n",
" _, prob = predict(self.model, self.scaler, [self.img])\n",
" ai, conf = CLASSES[int(prob.argmax())], float(prob.max())\n",
" ok = choice == self.answer\n",
" self.feedback = (\"정답\" if ok else f\"오답 - 정답은 {KOR[self.answer]}\") + f\" | AI 참고 판정: {KOR[ai]}({conf:.0%})\"\n",
" ms = (time.perf_counter() - t0) * 1000\n",
" self.history.append({\"문항\": self.i + 1, \"정답\": self.answer, \"선택\": choice, \"결과\": ok, \"조건\": self.cond, \"처리ms\": ms})\n",
" return ms\n",
"\n",
" def render(self):\n",
" done = len(self.history)\n",
" bar = \"#\" * done + \".\" * (len(self.bank) - done)\n",
" score = sum(h[\"결과\"] for h in self.history)\n",
" opts = \" \".join(f\"{k + 1}) {KOR[o]}\" for k, o in enumerate(self.options))\n",
" return f\"[문항 {self.i + 1}/{len(self.bank)}] 진행 {bar} 점수 {score}/{done}\\n 보기: {opts}\\n 피드백: {self.feedback or '-'}\"\n",
"\n",
"\n",
"model, scaler = train_model(120, seed=5)\n",
"rng = np.random.default_rng(8)\n",
"app = QuizApp(model, scaler, rng)\n",
"skill = {\"civil_quad\": .9, \"civil_fw\": .65, \"attack_fw\": .7, \"cruise\": .55, \"ballistic\": .6, \"bird\": .85} # 가상 학습자 숙련도\n",
"\n",
"refresh = []\n",
"for q in range(len(app.bank)):\n",
" t_screen = time.perf_counter()\n",
" correct = rng.random() < skill[app.answer]\n",
" pick = app.options.index(app.answer if correct else CONFUSE[app.answer]) + 1\n",
" app.submit(pick)\n",
" if q < 2:\n",
" print(app.render() + \"\\n\")\n",
" refresh.append((time.perf_counter() - t_screen) * 1000)\n",
" if q < len(app.bank) - 1:\n",
" app.i += 1; app.next_question()\n",
"\n",
"h = pd.DataFrame(app.history)\n",
"print(f\"화면 갱신 처리 최대 {max(refresh):.1f} ms (목표: 갱신 주기 1초 이내), 응답 처리 평균 {h['처리ms'].mean():.1f} ms / 최대 {h['처리ms'].max():.1f} ms (목표 100 ms 이내: {'달성' if h['처리ms'].max() <= 100 else '미달'})\")\n",
"print(f\"최종 점수 {h['결과'].sum()}/{len(h)}\")\n",
"print(\"\\n[클래스별 정답률]\")\n",
"print(h.groupby(\"정답\")[\"결과\"].mean().rename(index=KOR).round(2).to_string())\n",
"print(\"\\n[오답 목록]\")\n",
"print(h[~h[\"결과\"]][[\"문항\", \"정답\", \"선택\", \"조건\"]].to_string(index=False))\n",
"\n",
"# 학습 화면(야간 모드): 문제 이미지, 보기·피드백, 진행률, 클래스별 정답률, 응답시간 추이 - 시각화 구성요소 5종\n",
"plt.style.use(\"dark_background\")\n",
"fig = plt.figure(figsize=(12, 7))\n",
"ax1 = fig.add_subplot(2, 3, 1); ax1.imshow(app.img, cmap=\"gray\"); ax1.axis(\"off\"); ax1.set_title(f\"Q{app.i + 1}: identify the silhouette\")\n",
"ax2 = fig.add_subplot(2, 3, 2); ax2.axis(\"off\"); ax2.set_title(\"Options / feedback\")\n",
"last = app.history[-1]\n",
"for k, o in enumerate(app.options):\n",
" col = \"lime\" if o == app.answer else (\"red\" if o == last[\"선택\"] else \"white\")\n",
" ax2.text(0.05, 0.85 - k * 0.2, f\"{k + 1}) {o}\", color=col, fontsize=13, fontweight=\"bold\" if col != \"white\" else \"normal\")\n",
"ax3 = fig.add_subplot(2, 3, 3); s = h[\"결과\"].sum()\n",
"ax3.barh([0], [len(h)], color=\"dimgray\"); ax3.barh([0], [s], color=\"deepskyblue\"); ax3.set_yticks([])\n",
"ax3.set_title(f\"Progress / score {s}/{len(h)}\")\n",
"ax4 = fig.add_subplot(2, 3, 4); acc = h.groupby(\"정답\")[\"결과\"].mean()\n",
"ax4.bar(acc.index, acc.values, color=[\"red\" if v < 0.6 else \"deepskyblue\" for v in acc.values]); ax4.tick_params(axis=\"x\", rotation=40)\n",
"ax4.set_title(\"Accuracy by class (red = weak)\")\n",
"ax5 = fig.add_subplot(2, 3, 5); ax5.plot(h[\"문항\"], h[\"처리ms\"], \"o-\", color=\"orange\"); ax5.axhline(100, ls=\"--\", c=\"gray\")\n",
"ax5.set_title(\"Response time (ms)\"); ax5.set_xlabel(\"question\")\n",
"ax6 = fig.add_subplot(2, 3, 6); ax6.axis(\"off\")\n",
"ax6.text(0, 0.8, \"Night mode UI\\nCorrect: green / Wrong: red\\nAI hint shown only after answer\", fontsize=11, va=\"top\")\n",
"plt.suptitle(\"Silhouette ID training app prototype - training data only\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[문항 1/12] 진행 #........... 점수 1/1\n",
" 보기: 1) 조류 2) 민간 고정익형 3) 민간 쿼드콥터형 4) 삼각익 자폭드론형\n",
" 피드백: 정답 | AI 참고 판정: 민간 고정익형(96%)\n",
"\n",
"[문항 2/12] 진행 ##.......... 점수 1/2\n",
" 보기: 1) 탄도미사일형 2) 삼각익 자폭드론형 3) 순항미사일형 4) 민간 고정익형\n",
" 피드백: 오답 - 정답은 순항미사일형 | AI 참고 판정: 순항미사일형(83%)\n",
"\n",
"화면 갱신 처리 최대 8.5 ms (목표: 갱신 주기 1초 이내), 응답 처리 평균 4.4 ms / 최대 6.6 ms (목표 100 ms 이내: 달성)\n",
"최종 점수 9/12\n",
"\n",
"[클래스별 정답률]\n",
"정답\n",
"삼각익 자폭드론형 1.0\n",
"탄도미사일형 0.5\n",
"조류 1.0\n",
"민간 고정익형 1.0\n",
"민간 쿼드콥터형 1.0\n",
"순항미사일형 0.0\n",
"\n",
"[오답 목록]\n",
" 문항 정답 선택 조건\n",
" 2 cruise ballistic far\n",
" 6 ballistic cruise clutter\n",
" 10 cruise ballistic night\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/trn03_approval_workflow.py 및 docs/trn03_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) 해설 모듈을 구현한다. 모든 처리는 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n",
"import hashlib\n",
"import json\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 7] 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 7] 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"FEAT_KOR = {\"elong\": \"길쭉함\", \"fill\": \"채움률\", \"solidity\": \"볼록도\", \"n_parts\": \"분리 부분 수\", \"end_gap\": \"앞뒤 폭 차이\",\n",
" \"mid_bulge\": \"중앙부 날개 돌출\", \"w_max\": \"최대 폭(날개 폭)\", \"compact\": \"윤곽 복잡도\"}\n",
"KEY = {\"civil_quad\": \"원형 회전날개 4개\", \"civil_fw\": \"직선 주익과 꼬리날개\", \"attack_fw\": \"꼬리 없는 삼각형 날개\",\n",
" \"cruise\": \"가는 동체와 작은 중간 날개\", \"ballistic\": \"굵은 동체·뾰족한 머리·꼬리 핀\", \"bird\": \"갈매기형으로 굽은 날개\"}\n",
"ACTIONS = {\"APPROVE\": \"승인(공개)\", \"EDIT\": \"수정 후 승인\", \"REJECT\": \"반려(재작성)\"}\n",
"SLA_H = 24\n",
"\n",
"\n",
"def template(cls):\n",
" return draw(cls, np.random.default_rng(0), angle=0, scale=.85, fore=1.0, blur=0, noise=0, shift=0)\n",
"\n",
"\n",
"TEMPL = {c: template(c) for c in CLASSES}\n",
"TF = {c: shape_features(preprocess([TEMPL[c]])[\"mask\"][0]) for c in CLASSES}\n",
"SPREAD = {k: np.std([TF[c][k] for c in CLASSES]) + 1e-6 for k in FEATS}\n",
"\n",
"\n",
"def draft(answer, chosen):\n",
" \"\"\"AI 해설 초안: 두 클래스 기준 실루엣의 형태 특징 차이 상위 3개를 문장으로 만든다(초안일 뿐 공개하지 않음).\"\"\"\n",
" diff = sorted(FEATS, key=lambda k: -abs(TF[answer][k] - TF[chosen][k]) / SPREAD[k])[:3]\n",
" parts = [f\"{FEAT_KOR[k]} {TF[answer][k]:.2f} vs {TF[chosen][k]:.2f}\" for k in diff]\n",
" return f\"정답 {KOR[answer]}은(는) {KEY[answer]}, 선택한 {KOR[chosen]}은(는) {KEY[chosen]}. 차이: \" + \"; \".join(parts)\n",
"\n",
"\n",
"class ReviewQueue:\n",
" def __init__(self):\n",
" self.items, self.log, self.clock = {}, [], 0.0\n",
"\n",
" def _record(self, body):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" body = {**body, \"t_h\": self.clock}\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(self):\n",
" prev = \"GENESIS\"\n",
" for n, r in enumerate(self.log, 1):\n",
" body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n",
" if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n",
" return False, n\n",
" prev = r[\"hash\"]\n",
" return True, -1\n",
"\n",
" def submit(self, learner, answer, chosen, freq):\n",
" key = f\"{answer}->{chosen}\"\n",
" if key in self.items and self.items[key][\"status\"] == \"PENDING\": # 같은 혼동 쌍은 한 건으로 묶고 빈도만 올린다\n",
" self.items[key][\"learners\"].append(learner); self.items[key][\"freq\"] = freq\n",
" else:\n",
" self.items[key] = {\"key\": key, \"answer\": answer, \"chosen\": chosen, \"draft\": draft(answer, chosen), \"status\": \"PENDING\",\n",
" \"published\": None, \"learners\": [learner], \"freq\": freq, \"t_in\": self.clock, \"escalated\": False}\n",
" self._record({\"event\": \"SUBMIT\", \"key\": key, \"learner\": learner, \"by\": \"AI\"})\n",
"\n",
" def order(self):\n",
" todo = [i for i in self.items.values() if i[\"status\"] == \"PENDING\"]\n",
" return sorted(todo, key=lambda i: (not i[\"escalated\"], -i[\"freq\"], i[\"t_in\"]))\n",
"\n",
" def decide(self, key, action, instructor, reason, text=None, feedback=\"\"):\n",
" it = self.items.get(key)\n",
" if it is None or it[\"status\"] != \"PENDING\":\n",
" raise ValueError(\"검토 대기 중인 항목이 아님\")\n",
" if action not in ACTIONS or not instructor.startswith(\"INS\"):\n",
" raise ValueError(\"허용되지 않은 조치 또는 교관 ID 없음\")\n",
" if len(reason.strip()) < 5:\n",
" raise ValueError(\"판단 근거 5자 이상 필요\")\n",
" if action == \"EDIT\" and not text:\n",
" raise ValueError(\"수정 승인은 수정 해설문 필요\")\n",
" it[\"status\"] = {\"APPROVE\": \"APPROVED\", \"EDIT\": \"APPROVED\", \"REJECT\": \"REJECTED\"}[action]\n",
" it[\"published\"] = (text or it[\"draft\"]) if action != \"REJECT\" else None\n",
" it[\"wait_h\"] = self.clock - it[\"t_in\"]\n",
" self._record({\"event\": \"DECIDE\", \"key\": key, \"action\": action, \"by\": instructor, \"reason\": reason, \"feedback\": feedback})\n",
"\n",
" def check_sla(self):\n",
" out = []\n",
" for i in self.items.values():\n",
" if i[\"status\"] == \"PENDING\" and not i[\"escalated\"] and self.clock - i[\"t_in\"] > SLA_H:\n",
" i[\"escalated\"] = True; out.append(i[\"key\"])\n",
" self._record({\"event\": \"ESCALATE\", \"key\": i[\"key\"], \"to\": \"교육훈련 담당관\"})\n",
" return out\n",
"\n",
"\n",
"# [교육용가상자료] 학습자 20명의 오답 기록(가명 L01~L20)\n",
"rng = np.random.default_rng(4)\n",
"pairs = [(\"attack_fw\", \"civil_fw\")] * 6 + [(\"cruise\", \"ballistic\")] * 5 + [(\"civil_fw\", \"attack_fw\")] * 3 + [(\"bird\", \"civil_fw\")] * 2 + [(\"ballistic\", \"cruise\")] * 4\n",
"q = ReviewQueue()\n",
"freq = {}\n",
"for n, (a, c) in enumerate(rng.permutation(np.array(pairs, dtype=object))):\n",
" freq[(a, c)] = freq.get((a, c), 0) + 1\n",
" q.clock += 0.5\n",
" q.submit(f\"L{n + 1:02d}\", a, c, freq[(a, c)])\n",
"print(f\"오답 {len(pairs)}건 -> 해설 검토 항목 {len(q.items)}건 (혼동 쌍 단위), 공개된 해설 {sum(i['published'] is not None for i in q.items.values())}건\")\n",
"print(\"검토 순서(빈도순):\", [f\"{i['key']}({i['freq']})\" for i in q.order()])\n",
"print(\"\\n[AI 해설 초안 예시 - 교관 검토 전 비공개]\\n\" + q.order()[0][\"draft\"])\n",
"\n",
"for args in [(\"attack_fw->civil_fw\", \"APPROVE\", \"\", \"근거 충분\"), (\"attack_fw->civil_fw\", \"EDIT\", \"INS01\", \"표현 보완 필요\"), (\"cruise->ballistic\", \"PUBLISH\", \"INS01\", \"자동 공개\")]:\n",
" try:\n",
" q.decide(*args)\n",
" except ValueError as e:\n",
" print(\"차단:\", e)\n",
"\n",
"q.clock += 2; q.decide(\"attack_fw->civil_fw\", \"EDIT\", \"INS01\", \"꼬리날개 유무를 먼저 보도록 수정\",\n",
" text=\"꼬리날개가 없고 날개가 삼각형이면 자폭드론형, 직선 날개와 꼬리날개가 있으면 민간 고정익형이다.\", feedback=\"원거리 예시 추가 요망\")\n",
"q.clock += 3; q.decide(\"cruise->ballistic\", \"APPROVE\", \"INS02\", \"중간 날개 유무 설명이 정확함\")\n",
"q.clock += 1; q.decide(\"bird->civil_fw\", \"REJECT\", \"INS02\", \"조류 날갯짓 변형 설명 누락, 재작성\")\n",
"q.clock += 30\n",
"print(\"SLA 초과 상신:\", q.check_sla())\n",
"q.clock += 1; q.decide(\"ballistic->cruise\", \"APPROVE\", \"INS01\", \"몸통 굵기 비교가 명확함\")\n",
"\n",
"df = pd.DataFrame(q.items.values())\n",
"expected = len(pairs) + int((df[\"status\"] != \"PENDING\").sum()) + int(df[\"escalated\"].sum()) # 제출 + 결정 + 상신 이벤트 수\n",
"print(\"남은 검토 대기:\", [f\"{i['key']}(대기 {q.clock - i['t_in']:.1f}h)\" for i in q.order()])\n",
"print(\"\\n\" + df[[\"key\", \"freq\", \"status\", \"escalated\", \"wait_h\"]].fillna(\"-\").to_string(index=False))\n",
"print(f\"\\n자동 공개 0건(승인 없이 공개된 해설 {sum(1 for i in q.items.values() if i['published'] and i['status'] != 'APPROVED')}건), \"\n",
" f\"감사 로그 {len(q.log)}건, 로깅 성공률 {len(q.log) / expected:.0%}, 해시 체인 검증 {q.verify()}\")\n",
"q.log[3][\"learner\"] = \"L99\"\n",
"print(\"4번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify())\n",
"print(\"교관 피드백 입력 항목: 교관 ID, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견\")\n",
"\n",
"a, c = \"attack_fw\", \"civil_fw\"\n",
"fig, ax = plt.subplots(1, 3, figsize=(10, 3.6))\n",
"ax[0].imshow(TEMPL[a], cmap=\"gray\"); ax[0].set_title(f\"correct: {a}\")\n",
"ax[1].imshow(TEMPL[c], cmap=\"gray\"); ax[1].set_title(f\"chosen: {c}\")\n",
"ax[2].imshow((TEMPL[c].astype(float) - TEMPL[a]), cmap=\"coolwarm\"); ax[2].set_title(\"difference (red = only correct)\")\n",
"for x in ax:\n",
" x.axis(\"off\")\n",
"plt.suptitle(\"Wrong-answer comparison for instructor review (synthetic)\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"오답 20건 -> 해설 검토 항목 5건 (혼동 쌍 단위), 공개된 해설 0건\n",
"검토 순서(빈도순): ['attack_fw->civil_fw(6)', 'cruise->ballistic(5)', 'ballistic->cruise(4)', 'civil_fw->attack_fw(3)', 'bird->civil_fw(2)']\n",
"\n",
"[AI 해설 초안 예시 - 교관 검토 전 비공개]\n",
"정답 삼각익 자폭드론형은(는) 꼬리 없는 삼각형 날개, 선택한 민간 고정익형은(는) 직선 주익과 꼬리날개. 차이: 윤곽 복잡도 3.82 vs 7.95; 볼록도 0.86 vs 0.40; 채움률 0.51 vs 0.29\n",
"차단: 허용되지 않은 조치 또는 교관 ID 없음\n",
"차단: 수정 승인은 수정 해설문 필요\n",
"차단: 허용되지 않은 조치 또는 교관 ID 없음\n",
"SLA 초과 상신: ['civil_fw->attack_fw', 'ballistic->cruise']\n",
"남은 검토 대기: ['civil_fw->attack_fw(대기 46.0h)']\n",
"\n",
" key freq status escalated wait_h\n",
"attack_fw->civil_fw 6 APPROVED False 11.5\n",
"civil_fw->attack_fw 3 PENDING True -\n",
" cruise->ballistic 5 APPROVED False 13.5\n",
" ballistic->cruise 4 APPROVED True 44.5\n",
" bird->civil_fw 2 REJECTED False 10.0\n",
"\n",
"자동 공개 0건(승인 없이 공개된 해설 0건), 감사 로그 26건, 로깅 성공률 100%, 해시 체인 검증 (True, -1)\n",
"4번째 기록 변조 후 검증(False, 깨진 위치): (False, 4)\n",
"교관 피드백 입력 항목: 교관 ID, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 08. 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"흐린 저품질 실루엣 이미지 대량 분류 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다량의 흐리고 품질이 낮은 실루엣 이미지가 한꺼번에 들어올 때 분류 서비스가 멈추지 않고 안전하게 동작하는지 한계치를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **평시 100장에서 500% 증가한 600장까지 요청을 늘리고, 손상 입력과 극저화질 영상을 섞어 시스템 다운·결과 유실 여부 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `tests/trn03_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": [
"\"\"\"흐리고 품질이 낮은 실루엣 이미지를 평시의 최대 6배(500% 증가)까지 한꺼번에 넣고 손상 입력을 섞어, 분류 서비스가 멈추지 않는지(다운 0건)·결과가 빠지지 않는지(유실률 0%)·흐림 한계치·처리량·메모리·병목 구간을 검증한다.\"\"\"\n",
"import time\n",
"import tracemalloc\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"from sklearn.neural_network import MLPClassifier\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 8] 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 8] 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n",
"def to_input(F, N, scaler):\n",
" return np.c_[N, scaler.transform(F) * 0.3]\n",
"\n",
"\n",
"def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n",
" X, y, _ = make_dataset(n_per_class, seed, conds)\n",
" P, F, N = feature_matrix(X)\n",
" scaler = StandardScaler().fit(F)\n",
" model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n",
" model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n",
" return model, scaler\n",
"\n",
"\n",
"def predict(model, scaler, images):\n",
" \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n",
" P, F, N = feature_matrix(images)\n",
" return P, model.predict_proba(to_input(F, N, scaler))\n",
"\n",
"CAPACITY = 200 # 한 번에 처리하는 최대 장수(초과 시 대기열로 나눠 처리 = 성능저하 모드)\n",
"MIN_CONF = 0.5 # 이보다 낮으면 정답을 단정하지 않고 '판별 불가(교관 확인)'\n",
"\n",
"\n",
"def serve(requests, stage):\n",
" \"\"\"요청 전부에 결과를 하나씩 돌려준다. 손상 입력은 '입력 오류', 저신뢰는 '판별 불가'로 안전하게 처리한다.\"\"\"\n",
" results, valid = {}, []\n",
" for rid, img in requests:\n",
" a = np.asarray(img, dtype=float) if img is not None else None\n",
" if a is None or a.shape != (IMG, IMG) or np.isnan(a).mean() > 0.5:\n",
" results[rid] = \"입력 오류(재전송 요청)\"\n",
" else:\n",
" valid.append((rid, a))\n",
" for k in range(0, len(valid), CAPACITY): # 대기열 청크 처리\n",
" ids, imgs = zip(*valid[k:k + CAPACITY])\n",
" t0 = time.perf_counter(); P = preprocess(np.array(imgs)); t1 = time.perf_counter()\n",
" F = np.array([[d[f] for f in FEATS] for d in map(shape_features, P[\"mask\"])]); t2 = time.perf_counter()\n",
" prob = model.predict_proba(to_input(F, P[\"norm\"].reshape(len(F), -1), scaler)); t3 = time.perf_counter()\n",
" stage[\"전처리\"] += t1 - t0; stage[\"형태특징\"] += t2 - t1; stage[\"추론\"] += t3 - t2\n",
" for rid, ok, p in zip(ids, P[\"ok\"], prob):\n",
" results[rid] = CLASSES[int(p.argmax())] if ok and p.max() >= MIN_CONF else \"판별 불가(교관 확인)\"\n",
" return results\n",
"\n",
"\n",
"def load_case(n, seed, blur=(1.2, 2.2), corrupt=0.0):\n",
" rng = np.random.default_rng(seed)\n",
" y = rng.choice(CLASSES, n)\n",
" reqs = [(f\"R{i:05d}\", draw(c, rng, blur=rng.uniform(*blur), contrast=rng.uniform(.5, 1), noise=10)) for i, c in enumerate(y)]\n",
" for i in rng.choice(n, int(n * corrupt), replace=False):\n",
" reqs[i] = (reqs[i][0], [None, np.full((IMG, IMG), np.nan), np.zeros((20, 20))][i % 3])\n",
" return reqs, dict(zip([r[0] for r in reqs], y))\n",
"\n",
"\n",
"model, scaler = train_model(120, seed=5)\n",
"rows, stage = [], {\"전처리\": 0.0, \"형태특징\": 0.0, \"추론\": 0.0}\n",
"for name, n, kw in [(\"평시 100장\", 100, {}), (\"300% 증가 400장\", 400, {}), (\"500% 증가 600장\", 600, {}),\n",
" (\"500% + 손상 입력 3%\", 600, {\"corrupt\": .03}), (\"500% + 극저화질(흐림 3.0)\", 600, {\"blur\": (2.8, 3.2)})]:\n",
" reqs, truth = load_case(n, len(rows) + 1, **kw)\n",
" t0 = time.perf_counter()\n",
" try:\n",
" res, down = serve(reqs, stage), 0\n",
" except Exception as e:\n",
" res, down = {}, 1; print(\"다운:\", e)\n",
" dt = time.perf_counter() - t0\n",
" judged = [r for r, v in res.items() if v in CLASSES]\n",
" rows.append({\"상황\": name, \"요청\": n, \"응답\": len(res), \"유실률\": 1 - len(res) / n, \"다운\": down,\n",
" \"판별불가\": sum(v.startswith(\"판별\") for v in res.values()), \"입력오류\": sum(v.startswith(\"입력\") for v in res.values()),\n",
" \"판별 정확도\": np.mean([res[r] == truth[r] for r in judged]) if judged else 0, \"처리량(장/s)\": int(n / dt),\n",
" \"성능저하 모드\": f\"청크 {int(np.ceil(n / CAPACITY))}회\" if n > CAPACITY else \"-\"})\n",
"df = pd.DataFrame(rows)\n",
"print(df.round(3).to_string(index=False))\n",
"print(f\"\\n시스템 다운 {df['다운'].sum()}건, 최대 유실률 {df['유실률'].max():.0%} (목표 다운 0건·유실 0%: {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == 0 else '미달'})\")\n",
"\n",
"# 흐림 한계치: 흐림 정도를 올리며 정확도가 75% 아래로 떨어지는 지점\n",
"print(\"\\n흐림(sigma) 판별 정확도 판별불가 비율\")\n",
"limit = None\n",
"for b in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5]:\n",
" reqs, truth = load_case(150, 50, blur=(b, b))\n",
" res = serve(reqs, {\"전처리\": 0, \"형태특징\": 0, \"추론\": 0})\n",
" judged = [r for r, v in res.items() if v in CLASSES]\n",
" acc = np.mean([res[r] == truth[r] for r in judged])\n",
" if limit is None and acc < 0.75:\n",
" limit = b\n",
" print(f\"{b:>8} {acc:.3f} {1 - len(judged) / len(res):.3f}\")\n",
"print(f\"한계치: 흐림 {limit} 이상에서 정확도 75% 미만 -> 해당 영상은 학습 문항에서 제외하거나 '고난도'로 분류\")\n",
"\n",
"# 메모리 누수 확인: 같은 부하를 3회 반복해 메모리 증가량 측정\n",
"tracemalloc.start()\n",
"reqs, _ = load_case(300, 99)\n",
"mem = []\n",
"for _ in range(3):\n",
" serve(reqs, {\"전처리\": 0, \"형태특징\": 0, \"추론\": 0}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n",
"peak = tracemalloc.get_traced_memory()[1] / 1e6\n",
"tracemalloc.stop()\n",
"print(f\"반복 후 메모리 {', '.join(f'{m:.1f}' for m in mem)} MB (최대 {peak:.1f} MB), 증가 없음: {mem[-1] - mem[0] < 1}\")\n",
"tot = sum(stage.values())\n",
"print(\"병목 구간:\", \", \".join(f\"{k} {v / tot:.0%}\" for k, v in sorted(stage.items(), key=lambda x: -x[1])), \"-> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다\")\n",
"\n",
"plt.figure(figsize=(8, 4))\n",
"plt.bar(range(len(df)), df[\"처리량(장/s)\"], 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))]); plt.ylabel(\"images / s\")\n",
"plt.title(\"Stress test throughput (red = queued/degraded mode)\"); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
" 상황 요청 응답 유실률 다운 판별불가 입력오류 판별 정확도 처리량(장/s) 성능저하 모드\n",
" 평시 100장 100 100 0.0 0 37 0 0.889 731 -\n",
" 300% 증가 400장 400 400 0.0 0 155 0 0.918 706 청크 2회\n",
" 500% 증가 600장 600 600 0.0 0 221 0 0.918 707 청크 3회\n",
" 500% + 손상 입력 3% 600 600 0.0 0 216 18 0.915 802 청크 3회\n",
"500% + 극저화질(흐림 3.0) 600 600 0.0 0 366 0 0.654 660 청크 3회\n",
"\n",
"시스템 다운 0건, 최대 유실률 0% (목표 다운 0건·유실 0%: 달성)\n",
"\n",
"흐림(sigma) 판별 정확도 판별불가 비율\n",
" 0.5 1.000 0.020\n",
" 1.0 0.992 0.167\n",
" 1.5 0.937 0.367\n",
" 2.0 0.825 0.467\n",
" 2.5 0.710 0.587\n",
" 3.0 0.566 0.647\n",
" 3.5 0.462 0.653\n",
"한계치: 흐림 2.5 이상에서 정확도 75% 미만 -> 해당 영상은 학습 문항에서 제외하거나 '고난도'로 분류\n",
"반복 후 메모리 0.4, 0.4, 0.4 MB (최대 30.4 MB), 증가 없음: True\n",
"병목 구간: 형태특징 68%, 전처리 31%, 추론 1% -> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 09. AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터를 증강 재학습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 장병의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 분석하고, 취약 데이터를 증강해 재학습한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **가상 장병 40명의 학습 기록에서 다빈도 오답 쌍을 찾고, 기본 조건 중심으로 학습한 현행 모델의 오류를 조건·클래스별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/trn03_aar_report.md 및 configs/trn03_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(사후검토) 형식으로 장병 학습 기록의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 원인별로 분석하고, 취약 조건·클래스를 증강한 데이터로 재학습해 종합 정확도 향상·재발 오류·회귀 여부와 재보정 파라미터를 산출한다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"from sklearn.neural_network import MLPClassifier\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 9] AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 9] AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n",
"def to_input(F, N, scaler):\n",
" return np.c_[N, scaler.transform(F) * 0.3]\n",
"\n",
"\n",
"def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n",
" X, y, _ = make_dataset(n_per_class, seed, conds)\n",
" P, F, N = feature_matrix(X)\n",
" scaler = StandardScaler().fit(F)\n",
" model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n",
" model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n",
" return model, scaler\n",
"\n",
"\n",
"def predict(model, scaler, images):\n",
" \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n",
" P, F, N = feature_matrix(images)\n",
" return P, model.predict_proba(to_input(F, N, scaler))\n",
"\n",
"\n",
"def fit(X, y):\n",
" P, F, N = feature_matrix(X)\n",
" sc = StandardScaler().fit(F)\n",
" m = MLPClassifier((96,), max_iter=200, early_stopping=True, random_state=0).fit(to_input(F, N, sc), [CLASSES.index(v) for v in y])\n",
" return m, sc\n",
"\n",
"\n",
"def evaluate(m, sc, X):\n",
" return np.array(CLASSES)[predict(m, sc, X)[1].argmax(1)]\n",
"\n",
"\n",
"# [1. 무엇이 일어났나] 장병 학습 기록(가상 40명 x 30문항) 다빈도 오답\n",
"rng = np.random.default_rng(3)\n",
"CONF = {\"civil_fw\": (\"attack_fw\", .30), \"attack_fw\": (\"civil_fw\", .25), \"cruise\": (\"ballistic\", .40), \"ballistic\": (\"cruise\", .35),\n",
" \"bird\": (\"civil_fw\", .15), \"civil_quad\": (\"civil_fw\", .05)}\n",
"log = []\n",
"for s in range(40):\n",
" for q in range(30):\n",
" c = CLASSES[rng.integers(6)]\n",
" wrong, p = CONF[c]\n",
" log.append({\"장병\": f\"L{s + 1:02d}\", \"정답\": c, \"응답\": wrong if rng.random() < p else c})\n",
"lg = pd.DataFrame(log)\n",
"err = lg[lg[\"정답\"] != lg[\"응답\"]]\n",
"print(f\"[1. 무엇이 일어났나] 응답 {len(lg)}건 중 오답 {len(err)}건({len(err) / len(lg):.1%})\")\n",
"print(\"다빈도 오답 쌍:\", (err[\"정답\"] + \"->\" + err[\"응답\"]).value_counts().head(3).to_dict())\n",
"\n",
"# [2. 왜 일어났나] AI 분류기(현행: 기본·회전·저대비·야간 조건 중심 학습) 오류를 조건별로 분류\n",
"base_conds = (\"basic\", \"offcenter\", \"lowcon\", \"night\")\n",
"Xtr, ytr, _ = make_dataset(150, 5, base_conds)\n",
"m0, s0 = fit(Xtr, ytr)\n",
"Xev, yev, cev = make_dataset(110, 31, tuple(CONDITIONS)) # AAR 평가 세트(11개 조건)\n",
"p0 = evaluate(m0, s0, Xev)\n",
"acc0 = (p0 == yev).mean()\n",
"by = pd.Series(p0 != yev).groupby(cev).mean().sort_values(ascending=False)\n",
"print(f\"\\n[2. 왜 일어났나] 현행 모델 종합 정확도 {acc0:.3f}, 오류율 상위 조건:\", by.head(4).round(2).to_dict())\n",
"weak_cls = pd.Series(p0 != yev).groupby(yev).mean().sort_values(ascending=False)\n",
"print(\"오류율 상위 클래스:\", weak_cls.head(3).round(2).to_dict(), \"-> 원인: 학습에 원거리·흐림·가림·측면 시점 영상이 없음\")\n",
"\n",
"# [3. 무엇을 바꾸나] 취약 조건 증강 + 다빈도 오답 클래스 가중 증강 후 재학습\n",
"weak_conds = tuple(by.index[by > 0.15]) or tuple(CONDITIONS)\n",
"Xa, ya, _ = make_dataset(110, 6, weak_conds)\n",
"extra = [c for c in (\"cruise\", \"ballistic\", \"civil_fw\", \"attack_fw\")]\n",
"rng2 = np.random.default_rng(7)\n",
"Xb = np.array([draw_cond(c, weak_conds[i % len(weak_conds)], rng2) for i, c in enumerate(extra * 40)])\n",
"yb = np.array(extra * 40)\n",
"m1, s1 = fit(np.r_[Xtr, Xa, Xb], np.r_[ytr, ya, yb])\n",
"prob1 = predict(m1, s1, Xev)[1]\n",
"p1 = np.array(CLASSES)[prob1.argmax(1)]\n",
"acc1 = (p1 == yev).mean()\n",
"print(f\"\\n[3. 무엇을 바꾸나] 증강 조건 {list(weak_conds)}, 혼동 클래스 4종 각 40장 추가\")\n",
"print(f\"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.10 else '미달'})\")\n",
"\n",
"# 회귀 테스트: AAR 등록 오류의 재발, 기존 정답의 회귀\n",
"reg = p0 != yev\n",
"again = reg & (p1 != yev)\n",
"print(f\"AAR 등록 오류 {reg.sum()}건 중 재발 {again.sum()}건(목표 0건: {'달성' if again.sum() == 0 else '미달'}), 기존 정답 중 새로 틀린 회귀 {(~reg & (p1 != yev)).sum()}건\")\n",
"print(f\"재발 오류 중 신뢰도 0.5 미만이라 '교관 확인'으로 걸러지는 건 {(again & (prob1.max(1) < 0.5)).sum()}건, 단정 오답으로 남는 건 {(again & (prob1.max(1) >= 0.5)).sum()}건\")\n",
"comp = pd.DataFrame({\"재보정 전\": pd.Series(p0 == yev).groupby(cev).mean(), \"재보정 후\": pd.Series(p1 == yev).groupby(cev).mean()}).round(3)\n",
"print(comp.T.to_string())\n",
"print(\"남은 재발 오류는 결합(combo)·구름 조건에 몰려 있어 교관 확인 대상으로 두고, 다음 주기에 지속 학습으로 보완한다.\")\n",
"\n",
"tuned = (f\"augment_conditions: {list(weak_conds)}\\naugment_extra_per_class: {{cruise: 40, ballistic: 40, civil_fw: 40, attack_fw: 40}}\\n\"\n",
" f\"min_confidence: 0.5\\nquiz_mix: {{confusing_pairs: 0.4, weak_conditions: 0.3, random: 0.3}}\\nhidden_layer: 96\")\n",
"print(\"\\n[재보정 파라미터(trn03_tuned_params.yaml 내용)]\\n\" + tuned)\n",
"\n",
"plt.figure(figsize=(9, 4))\n",
"x = np.arange(len(comp))\n",
"plt.bar(x - 0.2, comp[\"재보정 전\"], 0.4, label=\"before AAR\"); plt.bar(x + 0.2, comp[\"재보정 후\"], 0.4, label=\"after augmentation\")\n",
"plt.xticks(x, comp.index, rotation=30); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR retraining effect by condition (synthetic)\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1. 무엇이 일어났나] 응답 1200건 중 오답 337건(28.1%)\n",
"다빈도 오답 쌍: {'cruise->ballistic': 90, 'civil_fw->attack_fw': 77, 'attack_fw->civil_fw': 66}\n",
"\n",
"[2. 왜 일어났나] 현행 모델 종합 정확도 0.770, 오류율 상위 조건: {'combo': 0.67, 'oblique': 0.62, 'occlude': 0.42, 'clutter': 0.38}\n",
"오류율 상위 클래스: {'cruise': 0.34, 'bird': 0.3, 'civil_fw': 0.27} -> 원인: 학습에 원거리·흐림·가림·측면 시점 영상이 없음\n",
"\n",
"[3. 무엇을 바꾸나] 증강 조건 ['combo', 'oblique', 'occlude', 'clutter', 'far'], 혼동 클래스 4종 각 40장 추가\n",
"종합 정확도 0.770 -> 0.873 (+10.3%p, 목표 +10%p: 달성)\n",
"AAR 등록 오류 152건 중 재발 67건(목표 0건: 미달), 기존 정답 중 새로 틀린 회귀 17건\n",
"재발 오류 중 신뢰도 0.5 미만이라 '교관 확인'으로 걸러지는 건 36건, 단정 오답으로 남는 건 31건\n",
" basic blur clutter combo far lowcon night noise oblique occlude offcenter\n",
"재보정 전 1.0 0.933 0.617 0.333 0.683 1.0 0.983 0.967 0.383 0.583 0.983\n",
"재보정 후 1.0 0.967 0.650 0.450 0.867 1.0 1.000 0.950 0.967 0.750 1.000\n",
"남은 재발 오류는 결합(combo)·구름 조건에 몰려 있어 교관 확인 대상으로 두고, 다음 주기에 지속 학습으로 보완한다.\n",
"\n",
"[재보정 파라미터(trn03_tuned_params.yaml 내용)]\n",
"augment_conditions: ['combo', 'oblique', 'occlude', 'clutter', 'far']\n",
"augment_extra_per_class: {cruise: 40, ballistic: 40, civil_fw: 40, attack_fw: 40}\n",
"min_confidence: 0.5\n",
"quiz_mix: {confusing_pairs: 0.4, weak_conditions: 0.3, random: 0.3}\n",
"hidden_layer: 96\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 10. 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **실루엣 생성부터 전처리, 분류, 신뢰도 등급, 퀴즈 운영, 교관 해설 검토까지 학습앱 전체를 통합하고 부대 교육 배포 가이드를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **AAR 재보정값을 적용한 통합 코드를 한 번에 실행해 손상 문항이 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/main_trn03.py, README.md, docs/trn03_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": [
"\"\"\"합성 실루엣 생성·검증부터 전처리, 분류(AAR 재보정값 적용), 신뢰도 등급, 학습 퀴즈 운영, 오답 해설 교관 검토 대기열까지 학습앱 전체를 한 파일로 통합 실행하고, 자체 점검·함수 커버리지·부대 교육 배포 브리핑 요약을 출력한다.\"\"\"\n",
"import hashlib\n",
"import json\n",
"import sys\n",
"import time\n",
"import types\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.path import Path as MplPath\n",
"from scipy import ndimage\n",
"from scipy.spatial import ConvexHull\n",
"from sklearn.neural_network import MLPClassifier\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n",
"# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n",
"# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n",
"# ---------------------------------------------------------------------------\n",
"IMG = 32\n",
"CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n",
"KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n",
" \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n",
"THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n",
"\n",
"\n",
"def _rect(x0, x1, y0, y1):\n",
" # [TODO : 학생 실습 10] 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _bar(p, q, w):\n",
" # [TODO : 학생 실습 10] 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"SHAPES = {\n",
" \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n",
" \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n",
" \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n",
" \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n",
" \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n",
" [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n",
" \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n",
" [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n",
" \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n",
" (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n",
"}\n",
"_S = 2\n",
"_g = (np.arange(IMG * _S) + 0.5) / _S\n",
"_GX, _GY = np.meshgrid(_g, _g)\n",
"_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n",
"_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n",
"\n",
"\n",
"def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n",
" clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n",
" \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n",
" angle = rng.uniform(0, 360) if angle is None else angle\n",
" scale = rng.uniform(.7, .95) if scale is None else scale\n",
" fore = rng.uniform(.85, 1.0) if fore is None else fore\n",
" th = np.radians(angle)\n",
" c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n",
" d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n",
" u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n",
" v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n",
" uv = np.c_[u, -v]\n",
" m = np.zeros(len(uv), bool)\n",
" near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n",
" for p in _PATHS[cls]:\n",
" m[near] |= p.contains_points(uv[near])\n",
" for cx, cy, r in SHAPES[cls][\"circ\"]:\n",
" m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n",
" cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n",
" if occlude > 0: # 부분 가림(구름 조각)\n",
" h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n",
" cov[y0:y0 + h, x0:x0 + h] = 0\n",
" bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n",
" fg = rng.uniform(30, 60)\n",
" img = bg * (1 - cov) + fg * cov\n",
" if clutter > 0: # 구름·배경 얼룩\n",
" img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n",
" if blur > 0:\n",
" img = ndimage.gaussian_filter(img, blur)\n",
" mean = img.mean()\n",
" img = mean + (img - mean) * contrast\n",
" img = img + rng.normal(0, noise, img.shape)\n",
" if invert: # 야간 열상(IR) 극성 반전\n",
" img = 255 - img\n",
" return np.clip(img, 0, 255).astype(np.uint8)\n",
"\n",
"\n",
"# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n",
"CONDITIONS = {\n",
" \"basic\": {},\n",
" \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n",
" \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n",
" \"blur\": {\"blur\": (1.2, 1.8)},\n",
" \"lowcon\": {\"contrast\": (.2, .35)},\n",
" \"clutter\": {\"clutter\": .8},\n",
" \"occlude\": {\"occlude\": .08},\n",
" \"oblique\": {\"fore\": (.35, .6)},\n",
" \"night\": {\"invert\": True, \"noise\": 10.0},\n",
" \"noise\": {\"noise\": 28.0},\n",
" \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n",
"}\n",
"\n",
"\n",
"def draw_cond(cls, cond, rng):\n",
" kw = {}\n",
" for k, v in CONDITIONS[cond].items():\n",
" kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n",
" return draw(cls, rng, **kw)\n",
"\n",
"\n",
"def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n",
" \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" X, y, c = [], [], []\n",
" for cls in CLASSES:\n",
" for i in range(n_per_class):\n",
" cond = conds[i % len(conds)]\n",
" X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n",
" return np.array(X), np.array(y), np.array(c)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n",
"OUT = 24 # 크기 정규화 결과(24x24)\n",
"NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n",
"FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n",
"\n",
"\n",
"def preprocess(images, noise_t=NOISE_T):\n",
" \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n",
" f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n",
" n = len(f)\n",
" bad = ~np.isfinite(f)\n",
" missing = bad.sum(axis=(1, 2))\n",
" if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n",
" med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n",
" f = np.where(bad, med[:, None, None], f)\n",
" f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n",
" border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n",
" inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n",
" f[inverted] = 255 - f[inverted]\n",
" med3 = ndimage.median_filter(f, size=(1, 3, 3))\n",
" noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n",
" noisy = noise > noise_t\n",
" f[noisy] = med3[noisy]\n",
" lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n",
" e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n",
" mask = e < _otsu(e)[:, None, None]\n",
" mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n",
" edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n",
" ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n",
" return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n",
" \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n",
" \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n",
"\n",
"\n",
"def _otsu(e, bins=64):\n",
" n = len(e)\n",
" idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n",
" hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n",
" centers = (np.arange(bins) + 0.5) / bins\n",
" w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n",
" tot, mt = w[:, -1:], m[:, -1:]\n",
" between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n",
" return (np.argmax(between, axis=1) + 1) / bins\n",
"\n",
"\n",
"def _normalize(obj, mask, ok):\n",
" \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n",
" n = len(obj)\n",
" w = mask.astype(float)\n",
" m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n",
" yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n",
" cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n",
" dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n",
" mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n",
" th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n",
" a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n",
" b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n",
" pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n",
" pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n",
" half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n",
" g = np.linspace(-1, 1, OUT)\n",
" U, V = np.meshgrid(g, g, indexing=\"ij\")\n",
" Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n",
" X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n",
" N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n",
" out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n",
" skew = (out * (U ** 3)).sum(axis=(1, 2))\n",
" out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n",
" out[~ok] = 0\n",
" return np.clip(out, 0, 1)\n",
"\n",
"\n",
"def shape_features(mask):\n",
" \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n",
" if mask.sum() < 4:\n",
" return dict.fromkeys(FEATS, 0.0)\n",
" yx = np.argwhere(mask).astype(float)\n",
" c = yx - yx.mean(axis=0)\n",
" val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n",
" t, s = c @ vec[:, 1], c @ vec[:, 0]\n",
" length, width = np.ptp(t) + 1, np.ptp(s) + 1\n",
" corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n",
" try:\n",
" hull = ConvexHull(corners).volume\n",
" except Exception:\n",
" hull = float(mask.sum())\n",
" bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n",
" w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n",
" boundary = mask & ~ndimage.binary_erosion(mask)\n",
" return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n",
" \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n",
" \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n",
" \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n",
"\n",
"\n",
"def feature_matrix(images):\n",
" \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n",
" P = preprocess(images)\n",
" F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n",
" return P, F, P[\"norm\"].reshape(len(F), -1)\n",
"\n",
"CONFIG = {\"augment_conditions\": [\"combo\", \"oblique\", \"occlude\", \"clutter\", \"far\"], \"extra_classes\": [\"cruise\", \"ballistic\", \"civil_fw\", \"attack_fw\"],\n",
" \"min_confidence\": 0.5, \"hidden_layer\": 96} # 미션 9 재보정값\n",
"CALLED = set()\n",
"\n",
"\n",
"def _tracer(frame, event, arg):\n",
" if event == \"call\" and frame.f_globals is globals():\n",
" CALLED.add(frame.f_code.co_name)\n",
"\n",
"\n",
"def validate(img):\n",
" a = np.asarray(img, dtype=float) if img is not None else None\n",
" return a is not None and a.shape == (IMG, IMG) and np.isfinite(a).all() and a.min() >= 0 and a.max() <= 255\n",
"\n",
"\n",
"def train():\n",
" X1, y1, _ = make_dataset(80, 5, tuple(CONDITIONS))\n",
" X2, y2, _ = make_dataset(40, 6, tuple(CONFIG[\"augment_conditions\"]))\n",
" rng = np.random.default_rng(7)\n",
" ex = CONFIG[\"extra_classes\"] * 20\n",
" X3 = np.array([draw_cond(c, CONFIG[\"augment_conditions\"][i % 5], rng) for i, c in enumerate(ex)])\n",
" X, y = np.r_[X1, X2, X3], np.r_[y1, y2, ex]\n",
" P, F, N = feature_matrix(X)\n",
" sc = StandardScaler().fit(F)\n",
" m = MLPClassifier((CONFIG[\"hidden_layer\"],), max_iter=200, early_stopping=True, random_state=0)\n",
" return m.fit(np.c_[N, sc.transform(F) * 0.3], [CLASSES.index(v) for v in y]), sc\n",
"\n",
"\n",
"def classify(m, sc, images):\n",
" P, F, N = feature_matrix(images)\n",
" prob = m.predict_proba(np.c_[N, sc.transform(F) * 0.3])\n",
" pred = np.array(CLASSES)[prob.argmax(1)]\n",
" conf = prob.max(1) * np.where(P[\"ok\"], 1, 0)\n",
" return pred, conf, np.where(conf >= 0.8, \"High\", np.where(conf >= CONFIG[\"min_confidence\"], \"Medium\", \"Low\"))\n",
"\n",
"\n",
"class Queue:\n",
" def __init__(self):\n",
" self.items, self.log = [], []\n",
"\n",
" def submit(self, rec):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" body = {\"event\": \"SUBMIT\", **rec}\n",
" self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n",
" self.items.append({**rec, \"status\": \"PENDING\"})\n",
"\n",
"\n",
"sys.settrace(_tracer)\n",
"t_start = time.perf_counter()\n",
"# 1. 학습\n",
"model, scaler = train()\n",
"# 2. 운용 데이터(교육장 배포 문항 300장) 검증 -> 손상 3장 차단\n",
"X, y, cond = make_dataset(50, 41, tuple(CONDITIONS))\n",
"items = list(X)\n",
"items[5], items[77], items[150] = None, np.zeros((20, 20)), np.full((IMG, IMG), np.nan)\n",
"ok_idx = [i for i, im in enumerate(items) if validate(im)]\n",
"rejected = len(items) - len(ok_idx)\n",
"Xo, yo = np.array([items[i] for i in ok_idx]), y[ok_idx]\n",
"t0 = time.perf_counter()\n",
"pred, conf, grade = classify(model, scaler, Xo)\n",
"ms = (time.perf_counter() - t0) * 1000 / len(Xo)\n",
"judged = grade != \"Low\"\n",
"acc_all = (pred == yo).mean()\n",
"acc_judged = (pred[judged] == yo[judged]).mean()\n",
"# 3. 퀴즈 운영(가상 장병 20명 x 10문항) -> 오답은 해설 검토 대기열(자동 공개 없음)\n",
"rng = np.random.default_rng(9)\n",
"q, answers = Queue(), []\n",
"CONFUSE = {\"civil_fw\": \"attack_fw\", \"attack_fw\": \"civil_fw\", \"cruise\": \"ballistic\", \"ballistic\": \"cruise\", \"bird\": \"civil_fw\", \"civil_quad\": \"civil_fw\"}\n",
"for s in range(20):\n",
" for k in rng.choice(len(yo), 10, replace=False):\n",
" ans = yo[k] if rng.random() < 0.75 else CONFUSE[yo[k]]\n",
" answers.append(ans == yo[k])\n",
" if ans != yo[k]:\n",
" q.submit({\"learner\": f\"L{s + 1:02d}\", \"answer\": str(yo[k]), \"chosen\": str(ans), \"ai_grade\": str(grade[k])})\n",
"sys.settrace(None)\n",
"total = time.perf_counter() - t_start\n",
"\n",
"defined = {n for n, f in globals().items() if isinstance(f, types.FunctionType) and f.__module__ == __name__ and not n.startswith(\"_tracer\")}\n",
"defined |= {\"submit\", \"__init__\"}\n",
"coverage = len(CALLED & defined) / len(defined)\n",
"chk = {\n",
" \"손상 데이터 3건 차단\": rejected == 3,\n",
" \"판별 대상 297장 처리\": len(Xo) == 297,\n",
" \"전체 분류 정확도 85% 이상\": acc_all >= 0.85,\n",
" \"판별(Medium 이상) 정확도 95% 이상\": acc_judged >= 0.95,\n",
" \"High 등급 정확도 >= Medium 등급 정확도\": (pred == yo)[grade == \"High\"].mean() >= (pred == yo)[grade == \"Medium\"].mean(),\n",
" \"장당 처리 200 ms 이내\": ms <= 200,\n",
" \"오답 해설 자동 공개 0건\": all(i[\"status\"] == \"PENDING\" for i in q.items),\n",
" \"감사 로그 건수 일치\": len(q.log) == len(q.items) == sum(not a for a in answers),\n",
" \"함수 커버리지 85% 이상\": coverage >= 0.85,\n",
" \"전체 실행 60초 이내\": total < 60,\n",
"}\n",
"for k, v in chk.items():\n",
" print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n",
"passed = sum(chk.values())\n",
"print(f\"자체 점검 {passed}/{len(chk)} 통과, 함수 커버리지 {coverage:.0%} ({len(CALLED & defined)}/{len(defined)}), 통합 실행 {'성공' if passed == len(chk) else '점검 필요'}\")\n",
"\n",
"fig, ax = plt.subplots(2, 2, figsize=(11, 8))\n",
"for j in range(6):\n",
" sub = ax[0, 0].inset_axes([j % 3 / 3, 1 - (j // 3 + 1) / 2, 0.32, 0.48])\n",
" i = np.flatnonzero(yo == CLASSES[j])[0]\n",
" sub.imshow(Xo[i], cmap=\"gray\"); sub.set_title(f\"{CLASSES[j]}\\n{pred[i]} ({grade[i]})\", fontsize=7); sub.axis(\"off\")\n",
"ax[0, 0].axis(\"off\"); ax[0, 0].set_title(\"Sample items: true / predicted (grade)\")\n",
"g = pd.Series(grade).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n",
"ax[0, 1].bar(g.index, g.values, color=[\"tab:green\", \"tab:orange\", \"tab:red\"]); ax[0, 1].set_title(\"Confidence grade of deployed items\")\n",
"acc_c = pd.Series(pred == yo).groupby(cond[ok_idx]).mean()\n",
"ax[1, 0].bar(acc_c.index, acc_c.values); ax[1, 0].tick_params(axis=\"x\", rotation=40); ax[1, 0].set_title(\"AI accuracy by condition\")\n",
"ax[1, 1].axis(\"off\")\n",
"ax[1, 1].text(0, .9, f\"Items {len(Xo)} (+{rejected} rejected)\\nAI accuracy {acc_all:.1%}\\nLearner acc {np.mean(answers):.1%}\\n\"\n",
" f\"Review queue {len(q.items)} pending\\nCoverage {coverage:.0%}\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=12, va=\"top\")\n",
"plt.suptitle(\"TRN03 silhouette ID app - integrated run (training data only)\"); plt.tight_layout(); plt.show()\n",
"\n",
"top = pd.Series([f\"{i['answer']}->{i['chosen']}\" for i in q.items]).value_counts().head(2)\n",
"print(f\"\"\"\n",
"[부대 교육 배포 브리핑 요약 - 교육용가상자료]\n",
"1. 구성: 합성 추상 실루엣 6종(위협 3종), 문항 {len(items)}장 중 {len(Xo)}장 배포, 손상 {rejected}장 자동 차단\n",
"2. AI 성능: 전체 정확도 {acc_all:.1%}, 신뢰도 Medium 이상 판별 정확도 {acc_judged:.1%}, Low {int((~judged).sum())}장은 교관 확인, 장당 {ms:.2f} ms\n",
"3. 학습 운영: 가상 장병 20명 정답률 {np.mean(answers):.1%}, 다빈도 오답 {', '.join(f'{k}({v})' for k, v in top.items())}\n",
"4. 교관 검토: 오답 해설 {len(q.items)}건 검토 대기, 자동 공개 0건, 감사 로그 {len(q.log)}건\n",
"5. 운용 유의: 실루엣은 기본 도형으로 만든 학습용 추상 이미지이며 실제 기종 식별 교범을 대체하지 않는다. 실제 표적 판단은 정식 교육과 지휘계통에 따른다.\n",
"6. 확장 로드맵: 학습 기록 내보내기 API(문항 ID·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스\"\"\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[PASS] 손상 데이터 3건 차단\n",
"[PASS] 판별 대상 297장 처리\n",
"[PASS] 전체 분류 정확도 85% 이상\n",
"[PASS] 판별(Medium 이상) 정확도 95% 이상\n",
"[PASS] High 등급 정확도 >= Medium 등급 정확도\n",
"[PASS] 장당 처리 200 ms 이내\n",
"[PASS] 오답 해설 자동 공개 0건\n",
"[PASS] 감사 로그 건수 일치\n",
"[PASS] 함수 커버리지 85% 이상\n",
"[PASS] 전체 실행 60초 이내\n",
"자체 점검 10/10 통과, 함수 커버리지 87% (13/15), 통합 실행 성공\n",
"\n",
"[부대 교육 배포 브리핑 요약 - 교육용가상자료]\n",
"1. 구성: 합성 추상 실루엣 6종(위협 3종), 문항 300장 중 297장 배포, 손상 3장 자동 차단\n",
"2. AI 성능: 전체 정확도 85.2%, 신뢰도 Medium 이상 판별 정확도 96.7%, Low 55장은 교관 확인, 장당 1.47 ms\n",
"3. 학습 운영: 가상 장병 20명 정답률 75.0%, 다빈도 오답 ballistic->cruise(12), civil_quad->civil_fw(11)\n",
"4. 교관 검토: 오답 해설 50건 검토 대기, 자동 공개 0건, 감사 로그 50건\n",
"5. 운용 유의: 실루엣은 기본 도형으로 만든 학습용 추상 이미지이며 실제 기종 식별 교범을 대체하지 않는다. 실제 표적 판단은 정식 교육과 지휘계통에 따른다.\n",
"6. 확장 로드맵: 학습 기록 내보내기 API(문항 ID·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스\n",
"```\n",
"</details>\n"
]
}
],
"metadata": {
"colab": {
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}