3952 lines
259 KiB
Plaintext
3952 lines
259 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"# 🎯 [ISR01] 공개 위성영상 장비·차량 객체분류 정확도 평가\n",
|
|
"### Google Colab 실습 환경 (정보·감시·정찰(ISR) / Level 3)\n",
|
|
"\n",
|
|
"[](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/ISR01/ISR01_solution.ipynb)\n",
|
|
"\n",
|
|
"---\n",
|
|
"\n",
|
|
"## 📌 1. 프로젝트 개요 및 배경\n",
|
|
"\n",
|
|
"| 항목 | 내용 |\n",
|
|
"| :--- | :--- |\n",
|
|
"| **과정 분류** | 정보·감시·정찰(ISR) (Level 3) |\n",
|
|
"| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n",
|
|
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
|
|
"\n",
|
|
"### 📖 프로젝트 목표\n",
|
|
"러시아-우크라이나 전쟁에서는 상용·공개 위성영상이 대량으로 공개되면서 영상에서 장비와 차량을 빠르게 분류하는 능력과 함께, 위장막·수목 차폐·구름·모의 위장 표적 때문에 생기는 오판독을 걸러내는 능력이 중요해졌다. 본 프로젝트에서는 실제 위성영상 대신 코드로 만든 교육용 합성 영상(광학·SAR 한 쌍)을 사용해 메타데이터 스키마 정의, 해상도·구름·방위 정규화 전처리, Baseline 분류, 은폐 조건 대응 정밀 분류, 신뢰도 점수와 오분류 필터, 식별 확률 지도 화면, 고가치 표적 판독 승인(HITL), 대량 타일 부하 시험, AAR 재보정, 최종 통합까지 단계별로 구현한다. 최종 목표는 모델 정확도를 조건별로 정직하게 평가하고, 확신이 낮거나 중요한 식별 결과는 반드시 영상분석관의 판독을 거치도록 설계하는 것이다.\n",
|
|
"\n",
|
|
"> **⚠️ [교육용 가상 데이터셋 원칙]**\n",
|
|
"> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n",
|
|
"> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n",
|
|
"\n",
|
|
"---\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# ============================================================================\n",
|
|
"# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n",
|
|
"# ============================================================================\n",
|
|
"!pip install -q numpy pandas matplotlib scipy scikit-learn\n",
|
|
"\n",
|
|
"import os\n",
|
|
"import sys\n",
|
|
"import time\n",
|
|
"import math\n",
|
|
"import json\n",
|
|
"import re\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"import warnings\n",
|
|
"warnings.filterwarnings('ignore')\n",
|
|
"\n",
|
|
"# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n",
|
|
"plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n",
|
|
"plt.rcParams['axes.unicode_minus'] = False\n",
|
|
"\n",
|
|
"print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 01. 상용 위성(광학/SAR) 영상 특성 분석 및 군용 장비/차량 메타데이터 규격 정의\n",
|
|
"**소요시간**: 약 1.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"오픈소스 광학 및 SAR 위성영상의 해상도, 메타데이터, 군용 장비/차량(전차, 자주포, 트럭) 분류 기준을 분석하고 데이터셋 스키마를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공개·상용 광학 및 SAR 위성영상의 해상도, 촬영 조건, 출처 메타데이터와 군용 장비·차량(전차, 자주포, 트럭) 분류 기준을 정리해 데이터셋 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **영상 출처(제공처·라이선스·촬영 시각), 센서(광학/SAR, 지상 해상도, 비스듬 촬영 각도, 태양 고도, 구름 비율), 전술 파라미터(기상, 위치 오차, 전달 지연), 객체 라벨(기종, 위치, 방위, 은폐 상태, 판독 상태) 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `data/schema/isr01_input_schema.json 및 docs/isr01_data_spec.md` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 정보·감시·정찰(ISR) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\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": [
|
|
"\"\"\"공개·상용 위성영상(광학/SAR)의 해상도·촬영 조건 메타데이터와 군용 장비·차량(전차, 자주포, 트럭) 라벨의 데이터셋 스키마를 정의하고, 가상 샘플 메타데이터를 검증한다.\"\"\"\n",
|
|
"import json\n",
|
|
"import re\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"\n",
|
|
"# 1) 스키마 정의: 영상 출처·라이선스, 센서·촬영 조건, 전술 파라미터, 객체 라벨(클래스·위치·방위·은폐 상태)\n",
|
|
"SCHEMA = {\n",
|
|
" \"required\": [\"image_id\", \"source\", \"sensor\", \"tactical\", \"labels\"],\n",
|
|
" \"fields\": {\n",
|
|
" \"image_id\": {\"type\": str, \"pattern\": r\"^IMG\\d{4}$\"},\n",
|
|
" },\n",
|
|
" \"source\": {\n",
|
|
" \"provider\": {\"type\": str, \"enum\": [\"open_data\", \"commercial_sample\", \"partner_share\"]},\n",
|
|
" \"license\": {\"type\": str, \"pattern\": r\"^\\S.{2,}$\"},\n",
|
|
" \"acquired_at\": {\"type\": str, \"pattern\": r\"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}Z$\"},\n",
|
|
" },\n",
|
|
" \"sensor\": {\n",
|
|
" \"type\": {\"type\": str, \"enum\": [\"optical\", \"SAR\"]},\n",
|
|
" \"gsd_m\": {\"type\": (int, float), \"min\": 0.1, \"max\": 30},\n",
|
|
" \"off_nadir_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 60},\n",
|
|
" \"sun_elevation_deg\": {\"type\": (int, float), \"min\": -10, \"max\": 90},\n",
|
|
" \"cloud_cover_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n",
|
|
" },\n",
|
|
" \"tactical\": {\n",
|
|
" \"weather\": {\"type\": str, \"enum\": [\"clear\", \"haze\", \"cloudy\", \"snow\"]},\n",
|
|
" \"geo_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 1000},\n",
|
|
" \"delivery_latency_h\": {\"type\": (int, float), \"min\": 0, \"max\": 720},\n",
|
|
" },\n",
|
|
" \"label\": {\n",
|
|
" \"class\": {\"type\": str, \"enum\": [\"tank\", \"spg\", \"truck\", \"civil_vehicle\", \"decoy\", \"unknown\"]},\n",
|
|
" \"bbox\": {\"type\": list, \"length\": 4},\n",
|
|
" \"orientation_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 360},\n",
|
|
" \"concealment\": {\"type\": str, \"enum\": [\"none\", \"camo_net\", \"tree\", \"cloud\", \"partial\"]},\n",
|
|
" \"status\": {\"type\": str, \"enum\": [\"auto_draft\", \"analyst_verified\"]},\n",
|
|
" },\n",
|
|
"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def check(name, value, rule):\n",
|
|
" \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n",
|
|
" if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n",
|
|
" return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n",
|
|
" errs = []\n",
|
|
" if \"enum\" in rule and value not in rule[\"enum\"]:\n",
|
|
" errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n",
|
|
" if \"min\" in rule and value < rule[\"min\"]:\n",
|
|
" errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n",
|
|
" if \"max\" in rule and value > rule[\"max\"]:\n",
|
|
" errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n",
|
|
" if \"length\" in rule and len(value) != rule[\"length\"]:\n",
|
|
" errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n",
|
|
" if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n",
|
|
" errs.append(f\"{name}: 형식 불일치 '{value}'\")\n",
|
|
" return errs\n",
|
|
"\n",
|
|
"\n",
|
|
"def validate(rec):\n",
|
|
" errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n",
|
|
" for k, rule in SCHEMA[\"fields\"].items():\n",
|
|
" if k in rec:\n",
|
|
" errs += check(k, rec[k], rule)\n",
|
|
" for group in (\"source\", \"sensor\", \"tactical\"):\n",
|
|
" for k, rule in SCHEMA[group].items():\n",
|
|
" if k not in rec.get(group, {}):\n",
|
|
" errs.append(f\"{group}.{k}: 필수 필드 누락\")\n",
|
|
" else:\n",
|
|
" errs += check(f\"{group}.{k}\", rec[group][k], rule)\n",
|
|
" for i, lab in enumerate(rec.get(\"labels\", [])):\n",
|
|
" for k, rule in SCHEMA[\"label\"].items():\n",
|
|
" if k not in lab:\n",
|
|
" errs.append(f\"labels[{i}].{k}: 필수 필드 누락\")\n",
|
|
" else:\n",
|
|
" errs += check(f\"labels[{i}].{k}\", lab[k], rule)\n",
|
|
" return errs\n",
|
|
"\n",
|
|
"\n",
|
|
"# 2) [교육용가상자료] 영상 메타데이터 20건 생성 (가상 날짜·수치, 실제 촬영 정보 아님)\n",
|
|
"rng = np.random.default_rng(1)\n",
|
|
"classes = SCHEMA[\"label\"][\"class\"][\"enum\"]\n",
|
|
"records = []\n",
|
|
"for i in range(20):\n",
|
|
" sensor = str(rng.choice([\"optical\", \"SAR\"]))\n",
|
|
" labels = [{\"class\": str(rng.choice(classes)), \"bbox\": [int(rng.integers(0, 480)), int(rng.integers(0, 480)), 32, 32],\n",
|
|
" \"orientation_deg\": float(round(rng.uniform(0, 360), 1)),\n",
|
|
" \"concealment\": str(rng.choice(SCHEMA[\"label\"][\"concealment\"][\"enum\"])), \"status\": \"auto_draft\"} for _ in range(3)]\n",
|
|
" records.append({\"image_id\": f\"IMG{i:04d}\",\n",
|
|
" \"source\": {\"provider\": str(rng.choice(SCHEMA[\"source\"][\"provider\"][\"enum\"])), \"license\": \"CC-BY-4.0(가상)\",\n",
|
|
" \"acquired_at\": f\"2026-0{int(rng.integers(1, 9))}-1{int(rng.integers(0, 9))}T0{int(rng.integers(0, 9))}:30Z\"},\n",
|
|
" \"sensor\": {\"type\": sensor, \"gsd_m\": float(round(rng.uniform(0.3, 3.0), 2)), \"off_nadir_deg\": float(round(rng.uniform(0, 40), 1)),\n",
|
|
" \"sun_elevation_deg\": float(round(rng.uniform(15, 70), 1)), \"cloud_cover_pct\": float(round(rng.uniform(0, 60), 1))},\n",
|
|
" \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"haze\", \"cloudy\", \"snow\"])),\n",
|
|
" \"geo_error_m\": float(round(rng.uniform(1, 30), 1)), \"delivery_latency_h\": float(round(rng.uniform(1, 72), 1))},\n",
|
|
" \"labels\": labels})\n",
|
|
"\n",
|
|
"# 3) 정상 데이터 검증\n",
|
|
"ok = sum(1 for r in records if not validate(r))\n",
|
|
"print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 (목표: 오류 0건 {'달성' if ok == len(records) else '미달'})\")\n",
|
|
"\n",
|
|
"# 4) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n",
|
|
"bad = json.loads(json.dumps(records[0]))\n",
|
|
"bad[\"sensor\"][\"gsd_m\"] = 0.0\n",
|
|
"bad[\"sensor\"][\"type\"] = \"IR\"\n",
|
|
"del bad[\"tactical\"][\"geo_error_m\"]\n",
|
|
"bad[\"labels\"][0][\"class\"] = \"bmp\"\n",
|
|
"bad[\"labels\"][1][\"bbox\"] = [1, 2, 3]\n",
|
|
"print(\"\\n[오류 주입 결과] 주입 5종\")\n",
|
|
"for e in validate(bad):\n",
|
|
" print(\" -\", e)\n",
|
|
"\n",
|
|
"# 5) 스키마 필드 정의서(명세서 초안)\n",
|
|
"rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n",
|
|
"for g in (\"source\", \"sensor\", \"tactical\", \"label\"):\n",
|
|
" for k, r in SCHEMA[g].items():\n",
|
|
" t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n",
|
|
" rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n",
|
|
"spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n",
|
|
"print(\"\\n[데이터 명세 요약]\")\n",
|
|
"print(spec.to_string(index=False))\n",
|
|
"print(f\"\\n필드 정의 {len(spec)}개(완성도 100%). 라벨 status는 'auto_draft'로 생성되고 영상분석관 판독 후에만 'analyst_verified'로 바뀐다.\")\n",
|
|
"print(\"분류 기준: tank(포탑+긴 포신), spg(후방 대형 포탑+매우 긴 포신), truck(운전석+적재함 분리), civil_vehicle(소형·밝은/어두운 단색), decoy(모의 위장 표적)\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"정상 샘플 20건 중 검증 통과 20건, 오류 0건 (목표: 오류 0건 달성)\n",
|
|
"\n",
|
|
"[오류 주입 결과] 주입 5종\n",
|
|
" - sensor.type: 허용되지 않은 값 'IR'\n",
|
|
" - sensor.gsd_m: 최소값 0.1 미만\n",
|
|
" - tactical.geo_error_m: 필수 필드 누락\n",
|
|
" - labels[0].class: 허용되지 않은 값 'bmp'\n",
|
|
" - labels[1].bbox: 길이 4 필요\n",
|
|
"\n",
|
|
"[데이터 명세 요약]\n",
|
|
" 그룹 필드 자료형 규칙\n",
|
|
" 기본 image_id str {'pattern': '^IMG\\\\d{4}$'}\n",
|
|
" source provider str {'enum': ['open_data', 'commercial_sample', 'partner_share']}\n",
|
|
" source license str {'pattern': '^\\\\S.{2,}$'}\n",
|
|
" source acquired_at str {'pattern': '^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}Z$'}\n",
|
|
" sensor type str {'enum': ['optical', 'SAR']}\n",
|
|
" sensor gsd_m number {'min': 0.1, 'max': 30}\n",
|
|
" sensor off_nadir_deg number {'min': 0, 'max': 60}\n",
|
|
" sensor sun_elevation_deg number {'min': -10, 'max': 90}\n",
|
|
" sensor cloud_cover_pct number {'min': 0, 'max': 100}\n",
|
|
"tactical weather str {'enum': ['clear', 'haze', 'cloudy', 'snow']}\n",
|
|
"tactical geo_error_m number {'min': 0, 'max': 1000}\n",
|
|
"tactical delivery_latency_h number {'min': 0, 'max': 720}\n",
|
|
" label class str {'enum': ['tank', 'spg', 'truck', 'civil_vehicle', 'decoy', 'unknown']}\n",
|
|
" label bbox list {'length': 4}\n",
|
|
" label orientation_deg number {'min': 0, 'max': 360}\n",
|
|
" label concealment str {'enum': ['none', 'camo_net', 'tree', 'cloud', 'partial']}\n",
|
|
" label status str {'enum': ['auto_draft', 'analyst_verified']}\n",
|
|
"\n",
|
|
"필드 정의 17개(완성도 100%). 라벨 status는 'auto_draft'로 생성되고 영상분석관 판독 후에만 'analyst_verified'로 바뀐다.\n",
|
|
"분류 기준: tank(포탑+긴 포신), spg(후방 대형 포탑+매우 긴 포신), truck(운전석+적재함 분리), civil_vehicle(소형·밝은/어두운 단색), decoy(모의 위장 표적)\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 02. 위성영상 해상도 표준화, 구름 차폐 제거 및 방위/스케일 정규화 전처리 모듈 구현\n",
|
|
"**소요시간**: 약 2.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"구름 차폐, 그림자 왜곡, 위성 촬영 각도에 따른 기하학적 왜곡을 보정하고 영상 해상도를 표준화하는 전처리 파이프라인을 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **구름 차폐, 비스듬 촬영 왜곡, 해상도 차이, 결측 픽셀을 보정하고 객체 방위와 축척을 정규화하는 위성영상 전처리 파이프라인을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **지상 해상도와 촬영 각도 메타데이터로 영상을 표준 해상도(0.5m/픽셀, 가상 기준)로 재표본화하고, 광학에서 밝고 매끈하지만 SAR에는 반사가 없는 영역을 구름으로 찾아 배경값으로 채운다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/preprocess/isr01_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": [
|
|
"\"\"\"위성영상 조각의 해상도(GSD)·비스듬 촬영 왜곡을 표준화하고, 구름 차폐 영역 처리, 결측 픽셀 보정, 객체 주축 기준 방위 정규화, 0~1 정규화를 수행하는 전처리 모듈을 구현해 처리 속도와 보정 효과를 검증한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n",
|
|
"# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"N = 32\n",
|
|
"STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n",
|
|
"CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n",
|
|
"MILITARY = {\"tank\", \"spg\", \"truck\"}\n",
|
|
"# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n",
|
|
"SHAPES = {\n",
|
|
" \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n",
|
|
" \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n",
|
|
" \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n",
|
|
" \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n",
|
|
" \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n",
|
|
" \"none\": [], # 객체 없는 배경 타일\n",
|
|
"}\n",
|
|
"SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n",
|
|
"\n",
|
|
"\n",
|
|
"def _mask(cls, angle, scale, squash=1.0):\n",
|
|
" \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n",
|
|
" yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
" yy = yy / squash\n",
|
|
" a = np.radians(angle)\n",
|
|
" u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n",
|
|
" v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n",
|
|
" return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_chip(cls, rng, cond=None):\n",
|
|
" \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n",
|
|
" c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n",
|
|
" gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n",
|
|
" scale = STD_GSD / gsd\n",
|
|
" angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n",
|
|
" squash = np.cos(np.radians(off))\n",
|
|
" sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n",
|
|
" sun_az = rng.uniform(0, 360)\n",
|
|
" bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n",
|
|
" parts = _mask(cls, angle, scale, squash)\n",
|
|
" body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n",
|
|
" opt = bg.copy()\n",
|
|
" # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n",
|
|
" L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n",
|
|
" sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n",
|
|
" opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n",
|
|
" if cls == \"civil\":\n",
|
|
" base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n",
|
|
" opt[body] = base + rng.normal(0, 8, body.sum())\n",
|
|
" roof = parts[1][0]\n",
|
|
" opt[roof] = base * 0.8\n",
|
|
" else:\n",
|
|
" ref = bg[body].mean() if body.any() else bg.mean()\n",
|
|
" for m, d in parts:\n",
|
|
" opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n",
|
|
" # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n",
|
|
" if c[\"camo\"] > 0 and cls != \"civil\":\n",
|
|
" net = ndimage.binary_dilation(body, iterations=2)\n",
|
|
" tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n",
|
|
" opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n",
|
|
" # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n",
|
|
" if c[\"occl\"] > 0:\n",
|
|
" trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n",
|
|
" near = ndimage.binary_dilation(body, iterations=3)\n",
|
|
" cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n",
|
|
" opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n",
|
|
" # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n",
|
|
" cmask = np.zeros((N, N), bool)\n",
|
|
" if c[\"cloud\"] > 0:\n",
|
|
" blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n",
|
|
" cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n",
|
|
" opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n",
|
|
" # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n",
|
|
" sar = np.full((N, N), 0.12)\n",
|
|
" sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n",
|
|
" edge = body & ~ndimage.binary_erosion(body)\n",
|
|
" sar[edge] *= 1.3 if cls in MILITARY else 1.0\n",
|
|
" sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n",
|
|
" if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n",
|
|
" down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n",
|
|
" opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n",
|
|
" meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n",
|
|
" \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n",
|
|
" \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n",
|
|
" return np.clip(opt, 0, 255), sar, meta\n",
|
|
"\n",
|
|
"\n",
|
|
"SCEN = { # 은폐·촬영 조건 시나리오 10종\n",
|
|
" \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n",
|
|
" \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n",
|
|
" \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n",
|
|
" for i in range(n):\n",
|
|
" cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n",
|
|
" O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n",
|
|
" m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n",
|
|
" M.append(m)\n",
|
|
" return O, S, M\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n",
|
|
"def _fit(a, fill):\n",
|
|
" \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n",
|
|
" out = np.full((N, N), fill, float)\n",
|
|
" h, w = a.shape\n",
|
|
" ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n",
|
|
" yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n",
|
|
" hh, ww = min(N, h), min(N, w)\n",
|
|
" out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"def cloud_mask(opt, sm, min_area=15):\n",
|
|
" \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n",
|
|
" bright = opt > 185\n",
|
|
" if bright.sum() < min_area:\n",
|
|
" return np.zeros_like(bright)\n",
|
|
" local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n",
|
|
" cand = bright & (local_sd < 12) & (sm < 0.3)\n",
|
|
" lab, n = ndimage.label(cand)\n",
|
|
" if n == 0:\n",
|
|
" return cand\n",
|
|
" sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n",
|
|
" return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n",
|
|
"\n",
|
|
"\n",
|
|
"def orientation(opt, sm):\n",
|
|
" \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n",
|
|
" m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n == 0:\n",
|
|
" return 0.0, m\n",
|
|
" c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n",
|
|
" m = lab == k\n",
|
|
" yy, xx = np.nonzero(m)\n",
|
|
" if len(yy) < 5:\n",
|
|
" return 0.0, m\n",
|
|
" dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n",
|
|
" return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n",
|
|
"\n",
|
|
"\n",
|
|
"_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
"\n",
|
|
"\n",
|
|
"def warp(stack, zy, zx, ang):\n",
|
|
" \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n",
|
|
" a = np.radians(ang)[:, None, None]\n",
|
|
" zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n",
|
|
" sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n",
|
|
" sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n",
|
|
" y0, x0 = sy.astype(int), sx.astype(int)\n",
|
|
" fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n",
|
|
" flat = stack.reshape(len(stack), stack.shape[1], -1)\n",
|
|
" lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n",
|
|
" g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n",
|
|
" out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n",
|
|
" return out.reshape(stack.shape)\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess_batch(O, S, M):\n",
|
|
" \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n",
|
|
" 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n",
|
|
" src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n",
|
|
" if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n",
|
|
" mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n",
|
|
" src = np.where(np.isnan(src), mean, src)\n",
|
|
" zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n",
|
|
" zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n",
|
|
" out = warp(src, zy, zx, np.zeros(len(M)))\n",
|
|
" sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n",
|
|
" angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n",
|
|
" for i in range(len(M)):\n",
|
|
" cm = cloud_mask(out[i, 0], sm[i])\n",
|
|
" if cm.any():\n",
|
|
" fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n",
|
|
" out[i, 0][cm] = fill\n",
|
|
" back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n",
|
|
" src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n",
|
|
" cloud[i] = cm.mean()\n",
|
|
" angs[i] = orientation(out[i, 0], sm[i])[0]\n",
|
|
" rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n",
|
|
" if rot.any():\n",
|
|
" out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n",
|
|
" return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(opt, sar, meta):\n",
|
|
" \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n",
|
|
" return preprocess_batch([opt], [sar], [meta])[0]\n",
|
|
"\n",
|
|
"\n",
|
|
"def extent(s):\n",
|
|
" \"\"\"평활화한 SAR 반사 마스크의 가로·세로 길이(픽셀): 객체 크기·방위 확인용.\"\"\"\n",
|
|
" m = ndimage.uniform_filter(s, 3) > 0.2\n",
|
|
" return int(m.any(axis=0).sum()), int(m.any(axis=1).sum())\n",
|
|
"\n",
|
|
"\n",
|
|
"O, S, M = make_dataset(400, seed=7, scen=tuple(SCEN))\n",
|
|
"rng = np.random.default_rng(2)\n",
|
|
"for i in rng.choice(len(O), 20, replace=False): # 센서 결측(줄 단위 NaN) 20장 주입\n",
|
|
" r = int(rng.integers(0, N - 3)); O[i, r:r + 3] = np.nan\n",
|
|
"\n",
|
|
"# 1) 처리 속도와 오류율(같은 400장을 3회 일괄 처리해 가장 빠른 회차로 처리량을 잰다: 다른 프로그램 부하 영향 최소화)\n",
|
|
"best, fails, out = 1e9, 0, []\n",
|
|
"for rep in range(3):\n",
|
|
" t0, f = time.perf_counter(), 0\n",
|
|
" try:\n",
|
|
" res = preprocess_batch(O, S, M) # 400장을 한 번에(벡터 연산) 처리\n",
|
|
" except Exception:\n",
|
|
" res, f = [], len(O)\n",
|
|
" best, fails, out = min(best, time.perf_counter() - t0), max(fails, f), res\n",
|
|
"dt = best\n",
|
|
"nan_left = sum(int(np.isnan(p[0]).sum() + np.isnan(p[1]).sum()) for p in out)\n",
|
|
"print(f\"전처리 {len(O)}장, {len(O) / dt:,.0f}장/초 (목표 1,000건/초 이상), 처리 실패 {fails}건, 남은 결측 {nan_left}픽셀 -> 오류율 {fails / len(O):.0%}\")\n",
|
|
"print(f\"결측 주입 20장 결측치 보정율 {100 if nan_left == 0 else 0}%\")\n",
|
|
"\n",
|
|
"# 2) 조건별 보정 효과\n",
|
|
"sc = np.array([m[\"scen\"] for m in M])\n",
|
|
"before_cloud = np.array([(o > 185).mean() for o in np.nan_to_num(O, nan=115)])\n",
|
|
"after_cloud = np.array([(p[0] > 185 / 255).mean() for p in out])\n",
|
|
"ang = np.array([orientation(p[0] * 255, ndimage.uniform_filter(p[1] * 1.5, 3))[0] for p in out])\n",
|
|
"horiz = (np.abs(ang) < 12) | (np.abs(ang) > 168)\n",
|
|
"ext = np.array([extent(p[1]) for p in out])\n",
|
|
"rows = []\n",
|
|
"for k in SCEN:\n",
|
|
" sel = sc == k\n",
|
|
" rows.append({\"조건\": k, \"밝은 구름 픽셀(전)\": f\"{before_cloud[sel].mean():.1%}\", \"(후)\": f\"{after_cloud[sel].mean():.1%}\",\n",
|
|
" \"방위 정규화 성공\": f\"{horiz[sel].mean():.0%}\", \"객체 길이/폭(px)\": f\"{ext[sel, 0].mean():.1f}/{ext[sel, 1].mean():.1f}\"})\n",
|
|
"print(pd.DataFrame(rows).to_string(index=False))\n",
|
|
"\n",
|
|
"# 3) 단위 테스트 5종\n",
|
|
"raw_len = np.array([extent(s / 1.5)[0] for s in S])\n",
|
|
"tests = {\n",
|
|
" \"출력 크기 32x32(광학·SAR)\": all(p[0].shape == (N, N) and p[1].shape == (N, N) for p in out),\n",
|
|
" \"값 범위 0~1, 결측 0\": all(p[0].min() >= 0 and p[0].max() <= 1 and not np.isnan(p[0]).any() for p in out),\n",
|
|
" \"구름 조건 밝은 픽셀 50% 이상 감소\": after_cloud[sc == \"cloud\"].mean() < 0.5 * before_cloud[sc == \"cloud\"].mean(),\n",
|
|
" \"맑은 조건 방위 정규화 90% 이상\": horiz[sc == \"clean\"].mean() >= 0.9,\n",
|
|
" \"저해상도 객체 길이 표준화(맑은 조건 대비 30% 이내)\": abs(ext[sc == \"low_res\", 0].mean() / ext[sc == \"clean\", 0].mean() - 1) < 0.3,\n",
|
|
"}\n",
|
|
"for k, v in tests.items():\n",
|
|
" print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n",
|
|
"print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n",
|
|
"print(f\"참고: 저해상도 원본의 객체 길이 {raw_len[sc == 'low_res'].mean():.1f}px -> 보정 후 {ext[sc == 'low_res', 0].mean():.1f}px\")\n",
|
|
"\n",
|
|
"# 4) 전후 비교 그림\n",
|
|
"fig, ax = plt.subplots(2, 5, figsize=(14, 6))\n",
|
|
"for j, k in enumerate([\"clean\", \"cloud\", \"off_nadir\", \"low_res\", \"camo\"]):\n",
|
|
" i = int(np.where(sc == k)[0][0])\n",
|
|
" ax[0, j].imshow(np.nan_to_num(O[i], nan=0), cmap=\"gray\", vmin=0, vmax=255); ax[0, j].set_title(f\"{k} raw ({M[i]['cls']})\")\n",
|
|
" ax[1, j].imshow(out[i][0], cmap=\"gray\", vmin=0, vmax=1); ax[1, j].set_title(\"preprocessed\")\n",
|
|
"for a in ax.ravel():\n",
|
|
" a.axis(\"off\")\n",
|
|
"plt.suptitle(\"Synthetic satellite chips: before / after preprocessing (training data)\"); plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"전처리 400장, 1,395장/초 (목표 1,000건/초 이상), 처리 실패 0건, 남은 결측 0픽셀 -> 오류율 0%\n",
|
|
"결측 주입 20장 결측치 보정율 100%\n",
|
|
" 조건 밝은 구름 픽셀(전) (후) 방위 정규화 성공 객체 길이/폭(px)\n",
|
|
" clean 0.2% 0.1% 98% 14.8/8.1\n",
|
|
" camo 0.4% 0.3% 100% 14.8/8.1\n",
|
|
" occl30 0.2% 0.1% 100% 14.8/8.0\n",
|
|
" occl50 0.1% 0.1% 100% 14.7/8.1\n",
|
|
" cloud 25.1% 2.1% 100% 14.7/8.2\n",
|
|
" low_sun 0.3% 0.2% 95% 14.4/8.5\n",
|
|
"off_nadir 0.3% 0.2% 100% 14.7/8.1\n",
|
|
" low_res 0.0% 0.0% 95% 14.2/9.3\n",
|
|
" speckle 0.3% 0.3% 100% 15.4/8.9\n",
|
|
"camo_occl 0.1% 0.0% 98% 14.6/8.2\n",
|
|
"[PASS] 출력 크기 32x32(광학·SAR)\n",
|
|
"[PASS] 값 범위 0~1, 결측 0\n",
|
|
"[PASS] 구름 조건 밝은 픽셀 50% 이상 감소\n",
|
|
"[PASS] 맑은 조건 방위 정규화 90% 이상\n",
|
|
"[PASS] 저해상도 객체 길이 표준화(맑은 조건 대비 30% 이내)\n",
|
|
"단위 테스트 5/5 통과\n",
|
|
"참고: 저해상도 원본의 객체 길이 5.6px -> 보정 후 14.2px\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 03. 위성영상 내 기갑/포병 장비 객체 탐지 Baseline CNN/Vision 모델 구현\n",
|
|
"**소요시간**: 약 2.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"위성영상 내 주요 군용 장비 및 차량 객체를 1차 식별하는 Baseline 합성곱 신경망(CNN) 기반 분류 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위성영상 속 주요 군용 장비와 차량을 1차 식별하는 Baseline 합성곱(CNN) 방식 분류 모델을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **Sobel·Laplacian·대각선·평균 필터로 이루어진 합성곱 필터 뱅크, ReLU, 최대 풀링으로 간이 CNN 특징을 만들고 로지스틱 회귀 분류기를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/models/isr01_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": [
|
|
"\"\"\"위성영상 조각에서 전차·자주포·트럭·민간 차량을 1차 식별하는 Baseline 분류 모델을 구현한다. 합성곱 필터 뱅크(Sobel·Laplacian·대각선·평균) -> ReLU -> 최대 풀링으로 만든 간이 CNN 특징에 로지스틱 회귀 분류기를 붙이고, 정확도·혼동행렬·판단 지연시간을 측정한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.linear_model import LogisticRegression\n",
|
|
"from sklearn.metrics import confusion_matrix\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n",
|
|
"# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"N = 32\n",
|
|
"STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n",
|
|
"CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n",
|
|
"MILITARY = {\"tank\", \"spg\", \"truck\"}\n",
|
|
"# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n",
|
|
"SHAPES = {\n",
|
|
" \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n",
|
|
" \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n",
|
|
" \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n",
|
|
" \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n",
|
|
" \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n",
|
|
" \"none\": [], # 객체 없는 배경 타일\n",
|
|
"}\n",
|
|
"SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n",
|
|
"\n",
|
|
"\n",
|
|
"def _mask(cls, angle, scale, squash=1.0):\n",
|
|
" \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n",
|
|
" yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
" yy = yy / squash\n",
|
|
" a = np.radians(angle)\n",
|
|
" u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n",
|
|
" v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n",
|
|
" return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_chip(cls, rng, cond=None):\n",
|
|
" \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n",
|
|
" c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n",
|
|
" gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n",
|
|
" scale = STD_GSD / gsd\n",
|
|
" angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n",
|
|
" squash = np.cos(np.radians(off))\n",
|
|
" sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n",
|
|
" sun_az = rng.uniform(0, 360)\n",
|
|
" bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n",
|
|
" parts = _mask(cls, angle, scale, squash)\n",
|
|
" body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n",
|
|
" opt = bg.copy()\n",
|
|
" # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n",
|
|
" L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n",
|
|
" sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n",
|
|
" opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n",
|
|
" if cls == \"civil\":\n",
|
|
" base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n",
|
|
" opt[body] = base + rng.normal(0, 8, body.sum())\n",
|
|
" roof = parts[1][0]\n",
|
|
" opt[roof] = base * 0.8\n",
|
|
" else:\n",
|
|
" ref = bg[body].mean() if body.any() else bg.mean()\n",
|
|
" for m, d in parts:\n",
|
|
" opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n",
|
|
" # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n",
|
|
" if c[\"camo\"] > 0 and cls != \"civil\":\n",
|
|
" net = ndimage.binary_dilation(body, iterations=2)\n",
|
|
" tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n",
|
|
" opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n",
|
|
" # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n",
|
|
" if c[\"occl\"] > 0:\n",
|
|
" trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n",
|
|
" near = ndimage.binary_dilation(body, iterations=3)\n",
|
|
" cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n",
|
|
" opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n",
|
|
" # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n",
|
|
" cmask = np.zeros((N, N), bool)\n",
|
|
" if c[\"cloud\"] > 0:\n",
|
|
" blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n",
|
|
" cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n",
|
|
" opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n",
|
|
" # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n",
|
|
" sar = np.full((N, N), 0.12)\n",
|
|
" sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n",
|
|
" edge = body & ~ndimage.binary_erosion(body)\n",
|
|
" sar[edge] *= 1.3 if cls in MILITARY else 1.0\n",
|
|
" sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n",
|
|
" if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n",
|
|
" down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n",
|
|
" opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n",
|
|
" meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n",
|
|
" \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n",
|
|
" \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n",
|
|
" return np.clip(opt, 0, 255), sar, meta\n",
|
|
"\n",
|
|
"\n",
|
|
"SCEN = { # 은폐·촬영 조건 시나리오 10종\n",
|
|
" \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n",
|
|
" \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n",
|
|
" \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n",
|
|
" for i in range(n):\n",
|
|
" cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n",
|
|
" O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n",
|
|
" m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n",
|
|
" M.append(m)\n",
|
|
" return O, S, M\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n",
|
|
"def _fit(a, fill):\n",
|
|
" \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n",
|
|
" out = np.full((N, N), fill, float)\n",
|
|
" h, w = a.shape\n",
|
|
" ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n",
|
|
" yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n",
|
|
" hh, ww = min(N, h), min(N, w)\n",
|
|
" out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"def cloud_mask(opt, sm, min_area=15):\n",
|
|
" \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n",
|
|
" bright = opt > 185\n",
|
|
" if bright.sum() < min_area:\n",
|
|
" return np.zeros_like(bright)\n",
|
|
" local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n",
|
|
" cand = bright & (local_sd < 12) & (sm < 0.3)\n",
|
|
" lab, n = ndimage.label(cand)\n",
|
|
" if n == 0:\n",
|
|
" return cand\n",
|
|
" sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n",
|
|
" return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n",
|
|
"\n",
|
|
"\n",
|
|
"def orientation(opt, sm):\n",
|
|
" \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n",
|
|
" m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n == 0:\n",
|
|
" return 0.0, m\n",
|
|
" c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n",
|
|
" m = lab == k\n",
|
|
" yy, xx = np.nonzero(m)\n",
|
|
" if len(yy) < 5:\n",
|
|
" return 0.0, m\n",
|
|
" dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n",
|
|
" return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n",
|
|
"\n",
|
|
"\n",
|
|
"_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
"\n",
|
|
"\n",
|
|
"def warp(stack, zy, zx, ang):\n",
|
|
" \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n",
|
|
" a = np.radians(ang)[:, None, None]\n",
|
|
" zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n",
|
|
" sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n",
|
|
" sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n",
|
|
" y0, x0 = sy.astype(int), sx.astype(int)\n",
|
|
" fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n",
|
|
" flat = stack.reshape(len(stack), stack.shape[1], -1)\n",
|
|
" lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n",
|
|
" g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n",
|
|
" out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n",
|
|
" return out.reshape(stack.shape)\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess_batch(O, S, M):\n",
|
|
" \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n",
|
|
" 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n",
|
|
" src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n",
|
|
" if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n",
|
|
" mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n",
|
|
" src = np.where(np.isnan(src), mean, src)\n",
|
|
" zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n",
|
|
" zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n",
|
|
" out = warp(src, zy, zx, np.zeros(len(M)))\n",
|
|
" sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n",
|
|
" angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n",
|
|
" for i in range(len(M)):\n",
|
|
" cm = cloud_mask(out[i, 0], sm[i])\n",
|
|
" if cm.any():\n",
|
|
" fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n",
|
|
" out[i, 0][cm] = fill\n",
|
|
" back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n",
|
|
" src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n",
|
|
" cloud[i] = cm.mean()\n",
|
|
" angs[i] = orientation(out[i, 0], sm[i])[0]\n",
|
|
" rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n",
|
|
" if rot.any():\n",
|
|
" out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n",
|
|
" return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(opt, sar, meta):\n",
|
|
" \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n",
|
|
" return preprocess_batch([opt], [sar], [meta])[0]\n",
|
|
"\n",
|
|
"# ---- 특징 추출 공통 함수 ----\n",
|
|
"KERNELS = [np.array(k, float) for k in (\n",
|
|
" [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n",
|
|
" [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n",
|
|
"\n",
|
|
"\n",
|
|
"def conv_features(o):\n",
|
|
" \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n",
|
|
" feats = []\n",
|
|
" for k in KERNELS:\n",
|
|
" r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n",
|
|
" feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" return np.concatenate(feats)\n",
|
|
"\n",
|
|
"\n",
|
|
"def hog(o, cell=8, bins=8):\n",
|
|
" gy, gx = np.gradient(o)\n",
|
|
" mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n",
|
|
" b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n",
|
|
" out = np.zeros((N // cell, N // cell, bins))\n",
|
|
" for i in range(N // cell):\n",
|
|
" for j in range(N // cell):\n",
|
|
" sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n",
|
|
" out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n",
|
|
" return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n",
|
|
"\n",
|
|
"\n",
|
|
"def shape_features(o, s):\n",
|
|
" \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n",
|
|
" 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" m = (sm > 0.22) | (dark > 0.12)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n:\n",
|
|
" c = lab[10:22, 8:24]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n",
|
|
" m = lab == k\n",
|
|
" p = m.sum(axis=0).astype(float)\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" if len(cols) == 0:\n",
|
|
" return np.zeros(8), o, s\n",
|
|
" thin = (p > 0) & (p <= 2)\n",
|
|
" left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n",
|
|
" if left > right:\n",
|
|
" o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" body = np.nonzero(p > 3)[0]\n",
|
|
" d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n",
|
|
" heavy = d.sum(axis=0)\n",
|
|
" centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n",
|
|
" turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n",
|
|
" return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n",
|
|
" m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n",
|
|
"\n",
|
|
"\n",
|
|
"def adv_features(o, s):\n",
|
|
" \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n",
|
|
" shp, o, s = shape_features(o, s)\n",
|
|
" c = s[8:24, 4:28]\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" m = sm > 0.2\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n",
|
|
" sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n",
|
|
"\n",
|
|
"\n",
|
|
"def features(O, S, M):\n",
|
|
" return np.array([conv_features(p[0]) for p in preprocess_batch(O, S, M)])\n",
|
|
"\n",
|
|
"\n",
|
|
"# 1) 기본 데이터셋: 은폐 없는 영상(학습 800장: 맑은 조건 / 시험 400장: 맑은 조건 + 낮은 태양고도 + 비스듬 촬영)\n",
|
|
"Otr, Str, Mtr = make_dataset(800, seed=1)\n",
|
|
"Ote, Ste, Mte = make_dataset(400, seed=2, scen=(\"clean\", \"clean\", \"low_sun\", \"off_nadir\"))\n",
|
|
"Xtr, ytr = features(Otr, Str, Mtr), [m[\"cls\"] for m in Mtr]\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"Xte = features(Ote, Ste, Mte)\n",
|
|
"yte = np.array([m[\"cls\"] for m in Mte])\n",
|
|
"\n",
|
|
"# 2) 규제 강도(C) 1차 튜닝: 학습 자료의 일부(검증용 200장)로 선택\n",
|
|
"best_c, best = None, -1\n",
|
|
"for c in [0.01, 0.05, 0.2, 1.0]:\n",
|
|
" mdl = make_pipeline(StandardScaler(), LogisticRegression(C=c, max_iter=3000)).fit(Xtr[:600], ytr[:600])\n",
|
|
" acc = mdl.score(Xtr[600:], ytr[600:])\n",
|
|
" print(f\"C={c:<5} 검증 정확도 {acc:.3f}\")\n",
|
|
" if acc > best:\n",
|
|
" best_c, best = c, acc\n",
|
|
"model = make_pipeline(StandardScaler(), LogisticRegression(C=best_c, max_iter=3000)).fit(Xtr, ytr)\n",
|
|
"pred = model.predict(Xte)\n",
|
|
"lat = (time.perf_counter() - t0) / len(Xte) * 1000\n",
|
|
"acc = (pred == yte).mean()\n",
|
|
"print(f\"\\n선택 C={best_c}, 기본 시험셋 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), 1장당 판단 {lat:.2f} ms(전처리 포함)\")\n",
|
|
"\n",
|
|
"print(\"\\n[혼동행렬] 행=실제, 열=예측\")\n",
|
|
"print(pd.DataFrame(confusion_matrix(yte, pred, labels=CLASSES), index=CLASSES, columns=CLASSES).to_string())\n",
|
|
"mil, pm = np.isin(yte, list(MILITARY)), np.isin(pred, list(MILITARY))\n",
|
|
"print(f\"군용 장비 여부 판정 정확도 {(mil == pm).mean():.1%}, 클래스별 재현율:\",\n",
|
|
" {c: round(float((pred[yte == c] == c).mean()), 2) for c in CLASSES})\n",
|
|
"\n",
|
|
"# 3) 한계: 위장막·수목 차폐·저해상도 등 은폐 조건에서는 정확도가 떨어진다(미션 4의 동기)\n",
|
|
"Oh, Sh, Mh = make_dataset(400, seed=3, scen=(\"camo\", \"occl50\", \"low_res\", \"camo_occl\"))\n",
|
|
"ph = model.predict(features(Oh, Sh, Mh))\n",
|
|
"yh, sh = np.array([m[\"cls\"] for m in Mh]), np.array([m[\"scen\"] for m in Mh])\n",
|
|
"print(\"[은폐 조건 정확도]\", {k: round(float((ph[sh == k] == yh[sh == k]).mean()), 2) for k in (\"camo\", \"occl50\", \"low_res\", \"camo_occl\")})\n",
|
|
"print(\"판단 근거: 필터 반응(윤곽·포신 같은 가는 선)의 위치 분포. 위장막이 윤곽을 지우면 근거가 사라진다.\")\n",
|
|
"\n",
|
|
"# 4) 그림: 클래스별 예시와 필터 반응\n",
|
|
"fig, ax = plt.subplots(2, 4, figsize=(12, 6))\n",
|
|
"for j, c in enumerate(CLASSES):\n",
|
|
" i = int(np.where(yte == c)[0][0])\n",
|
|
" o = preprocess(Ote[i], Ste[i], Mte[i])[0]\n",
|
|
" ax[0, j].imshow(o, cmap=\"gray\"); ax[0, j].set_title(f\"{c} -> pred {pred[i]}\")\n",
|
|
" ax[1, j].imshow(np.maximum(ndimage.correlate(o - o.mean(), KERNELS[0] / 8), 0), cmap=\"magma\"); ax[1, j].set_title(\"Sobel-x response\")\n",
|
|
"for a in ax.ravel():\n",
|
|
" a.axis(\"off\")\n",
|
|
"plt.suptitle(\"Baseline conv-feature classifier (synthetic chips)\"); plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"C=0.01 검증 정확도 1.000\n",
|
|
"C=0.05 검증 정확도 1.000\n",
|
|
"C=0.2 검증 정확도 1.000\n",
|
|
"C=1.0 검증 정확도 1.000\n",
|
|
"\n",
|
|
"선택 C=0.01, 기본 시험셋 정확도 96.5% (목표 75% 이상: 달성), 1장당 판단 5.38 ms(전처리 포함)\n",
|
|
"\n",
|
|
"[혼동행렬] 행=실제, 열=예측\n",
|
|
" tank spg truck civil\n",
|
|
"tank 91 8 1 0\n",
|
|
"spg 1 99 0 0\n",
|
|
"truck 1 3 96 0\n",
|
|
"civil 0 0 0 100\n",
|
|
"군용 장비 여부 판정 정확도 100.0%, 클래스별 재현율: {'tank': 0.91, 'spg': 0.99, 'truck': 0.96, 'civil': 1.0}\n",
|
|
"[은폐 조건 정확도] {'camo': 0.5, 'occl50': 0.52, 'low_res': 0.25, 'camo_occl': 0.61}\n",
|
|
"판단 근거: 필터 반응(윤곽·포신 같은 가는 선)의 위치 분포. 위장막이 윤곽을 지우면 근거가 사라진다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 04. 위장막/차폐 환경 하 군용 장비 미세 특징 추출 및 정밀 객체분류 모델 고도화\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"위장막 설치, 수목 차폐, 부분 은폐 상태의 군용 장비 미세 특징을 추출하여 객체분류 정확도를 극대화하는 정밀 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위장막 설치, 수목 차폐, 부분 은폐 상태의 장비에서도 기종을 식별하도록 미세 특징을 추출하는 정밀 분류 모델로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **객체 모양(전체 길이·차체 길이·포신 돌출·포탑 위치), 윤곽 방향 히스토그램(HOG), 위장막과 구름에 강한 SAR 반사 통계, 투영 윤곽 특징을 결합하고 은폐 조건 10종을 섞은 증강 자료로 랜덤 포레스트를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/models/isr01_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": [
|
|
"\"\"\"위장막·수목 차폐·구름·저해상도·SAR 잡음 등 은폐 조건 10종에서 군용 장비를 놓치지 않도록, 윤곽(HOG)·SAR 반사 통계·투영 윤곽 특징과 은폐 조건 증강 학습을 결합한 정밀 객체분류 모델(랜덤 포레스트)로 고도화하고 조건별 재현율과 처리 지연을 측정한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"from sklearn.linear_model import LogisticRegression\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n",
|
|
"# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"N = 32\n",
|
|
"STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n",
|
|
"CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n",
|
|
"MILITARY = {\"tank\", \"spg\", \"truck\"}\n",
|
|
"# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n",
|
|
"SHAPES = {\n",
|
|
" \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n",
|
|
" \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n",
|
|
" \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n",
|
|
" \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n",
|
|
" \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n",
|
|
" \"none\": [], # 객체 없는 배경 타일\n",
|
|
"}\n",
|
|
"SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n",
|
|
"\n",
|
|
"\n",
|
|
"def _mask(cls, angle, scale, squash=1.0):\n",
|
|
" \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n",
|
|
" yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
" yy = yy / squash\n",
|
|
" a = np.radians(angle)\n",
|
|
" u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n",
|
|
" v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n",
|
|
" return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_chip(cls, rng, cond=None):\n",
|
|
" \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n",
|
|
" c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n",
|
|
" gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n",
|
|
" scale = STD_GSD / gsd\n",
|
|
" angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n",
|
|
" squash = np.cos(np.radians(off))\n",
|
|
" sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n",
|
|
" sun_az = rng.uniform(0, 360)\n",
|
|
" bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n",
|
|
" parts = _mask(cls, angle, scale, squash)\n",
|
|
" body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n",
|
|
" opt = bg.copy()\n",
|
|
" # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n",
|
|
" L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n",
|
|
" sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n",
|
|
" opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n",
|
|
" if cls == \"civil\":\n",
|
|
" base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n",
|
|
" opt[body] = base + rng.normal(0, 8, body.sum())\n",
|
|
" roof = parts[1][0]\n",
|
|
" opt[roof] = base * 0.8\n",
|
|
" else:\n",
|
|
" ref = bg[body].mean() if body.any() else bg.mean()\n",
|
|
" for m, d in parts:\n",
|
|
" opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n",
|
|
" # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n",
|
|
" if c[\"camo\"] > 0 and cls != \"civil\":\n",
|
|
" net = ndimage.binary_dilation(body, iterations=2)\n",
|
|
" tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n",
|
|
" opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n",
|
|
" # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n",
|
|
" if c[\"occl\"] > 0:\n",
|
|
" trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n",
|
|
" near = ndimage.binary_dilation(body, iterations=3)\n",
|
|
" cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n",
|
|
" opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n",
|
|
" # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n",
|
|
" cmask = np.zeros((N, N), bool)\n",
|
|
" if c[\"cloud\"] > 0:\n",
|
|
" blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n",
|
|
" cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n",
|
|
" opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n",
|
|
" # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n",
|
|
" sar = np.full((N, N), 0.12)\n",
|
|
" sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n",
|
|
" edge = body & ~ndimage.binary_erosion(body)\n",
|
|
" sar[edge] *= 1.3 if cls in MILITARY else 1.0\n",
|
|
" sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n",
|
|
" if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n",
|
|
" down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n",
|
|
" opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n",
|
|
" meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n",
|
|
" \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n",
|
|
" \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n",
|
|
" return np.clip(opt, 0, 255), sar, meta\n",
|
|
"\n",
|
|
"\n",
|
|
"SCEN = { # 은폐·촬영 조건 시나리오 10종\n",
|
|
" \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n",
|
|
" \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n",
|
|
" \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n",
|
|
" for i in range(n):\n",
|
|
" cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n",
|
|
" O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n",
|
|
" m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n",
|
|
" M.append(m)\n",
|
|
" return O, S, M\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n",
|
|
"def _fit(a, fill):\n",
|
|
" \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n",
|
|
" out = np.full((N, N), fill, float)\n",
|
|
" h, w = a.shape\n",
|
|
" ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n",
|
|
" yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n",
|
|
" hh, ww = min(N, h), min(N, w)\n",
|
|
" out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"def cloud_mask(opt, sm, min_area=15):\n",
|
|
" \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n",
|
|
" bright = opt > 185\n",
|
|
" if bright.sum() < min_area:\n",
|
|
" return np.zeros_like(bright)\n",
|
|
" local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n",
|
|
" cand = bright & (local_sd < 12) & (sm < 0.3)\n",
|
|
" lab, n = ndimage.label(cand)\n",
|
|
" if n == 0:\n",
|
|
" return cand\n",
|
|
" sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n",
|
|
" return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n",
|
|
"\n",
|
|
"\n",
|
|
"def orientation(opt, sm):\n",
|
|
" \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n",
|
|
" m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n == 0:\n",
|
|
" return 0.0, m\n",
|
|
" c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n",
|
|
" m = lab == k\n",
|
|
" yy, xx = np.nonzero(m)\n",
|
|
" if len(yy) < 5:\n",
|
|
" return 0.0, m\n",
|
|
" dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n",
|
|
" return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n",
|
|
"\n",
|
|
"\n",
|
|
"_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
"\n",
|
|
"\n",
|
|
"def warp(stack, zy, zx, ang):\n",
|
|
" \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n",
|
|
" a = np.radians(ang)[:, None, None]\n",
|
|
" zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n",
|
|
" sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n",
|
|
" sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n",
|
|
" y0, x0 = sy.astype(int), sx.astype(int)\n",
|
|
" fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n",
|
|
" flat = stack.reshape(len(stack), stack.shape[1], -1)\n",
|
|
" lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n",
|
|
" g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n",
|
|
" out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n",
|
|
" return out.reshape(stack.shape)\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess_batch(O, S, M):\n",
|
|
" \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n",
|
|
" 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n",
|
|
" src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n",
|
|
" if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n",
|
|
" mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n",
|
|
" src = np.where(np.isnan(src), mean, src)\n",
|
|
" zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n",
|
|
" zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n",
|
|
" out = warp(src, zy, zx, np.zeros(len(M)))\n",
|
|
" sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n",
|
|
" angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n",
|
|
" for i in range(len(M)):\n",
|
|
" cm = cloud_mask(out[i, 0], sm[i])\n",
|
|
" if cm.any():\n",
|
|
" fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n",
|
|
" out[i, 0][cm] = fill\n",
|
|
" back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n",
|
|
" src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n",
|
|
" cloud[i] = cm.mean()\n",
|
|
" angs[i] = orientation(out[i, 0], sm[i])[0]\n",
|
|
" rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n",
|
|
" if rot.any():\n",
|
|
" out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n",
|
|
" return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(opt, sar, meta):\n",
|
|
" \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n",
|
|
" return preprocess_batch([opt], [sar], [meta])[0]\n",
|
|
"\n",
|
|
"# ---- 특징 추출 공통 함수 ----\n",
|
|
"KERNELS = [np.array(k, float) for k in (\n",
|
|
" [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n",
|
|
" [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n",
|
|
"\n",
|
|
"\n",
|
|
"def conv_features(o):\n",
|
|
" \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n",
|
|
" feats = []\n",
|
|
" for k in KERNELS:\n",
|
|
" r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n",
|
|
" feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" return np.concatenate(feats)\n",
|
|
"\n",
|
|
"\n",
|
|
"def hog(o, cell=8, bins=8):\n",
|
|
" gy, gx = np.gradient(o)\n",
|
|
" mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n",
|
|
" b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n",
|
|
" out = np.zeros((N // cell, N // cell, bins))\n",
|
|
" for i in range(N // cell):\n",
|
|
" for j in range(N // cell):\n",
|
|
" sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n",
|
|
" out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n",
|
|
" return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n",
|
|
"\n",
|
|
"\n",
|
|
"def shape_features(o, s):\n",
|
|
" \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n",
|
|
" 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" m = (sm > 0.22) | (dark > 0.12)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n:\n",
|
|
" c = lab[10:22, 8:24]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n",
|
|
" m = lab == k\n",
|
|
" p = m.sum(axis=0).astype(float)\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" if len(cols) == 0:\n",
|
|
" return np.zeros(8), o, s\n",
|
|
" thin = (p > 0) & (p <= 2)\n",
|
|
" left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n",
|
|
" if left > right:\n",
|
|
" o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" body = np.nonzero(p > 3)[0]\n",
|
|
" d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n",
|
|
" heavy = d.sum(axis=0)\n",
|
|
" centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n",
|
|
" turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n",
|
|
" return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n",
|
|
" m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n",
|
|
"\n",
|
|
"\n",
|
|
"def adv_features(o, s):\n",
|
|
" \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n",
|
|
" shp, o, s = shape_features(o, s)\n",
|
|
" c = s[8:24, 4:28]\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" m = sm > 0.2\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n",
|
|
" sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n",
|
|
"\n",
|
|
"\n",
|
|
"def prep(O, S, M):\n",
|
|
" return preprocess_batch(O, S, M)\n",
|
|
"\n",
|
|
"\n",
|
|
"ALL = tuple(SCEN)\n",
|
|
"# 1) 학습: 은폐 조건 10종을 고르게 섞은 증강 학습 자료 1,200장\n",
|
|
"Otr, Str, Mtr = make_dataset(1200, seed=3, scen=ALL)\n",
|
|
"Ptr = prep(Otr, Str, Mtr); ytr = [m[\"cls\"] for m in Mtr]\n",
|
|
"adv = RandomForestClassifier(n_estimators=200, min_samples_leaf=2, random_state=0, n_jobs=1)\n",
|
|
"adv.fit(np.array([adv_features(o, s) for o, s, _ in Ptr]), ytr)\n",
|
|
"base = make_pipeline(StandardScaler(), LogisticRegression(C=0.05, max_iter=3000)) # 비교용 Baseline(맑은 조건만 학습)\n",
|
|
"Oc, Sc, Mc = make_dataset(600, seed=1)\n",
|
|
"base.fit(np.array([conv_features(p[0]) for p in prep(Oc, Sc, Mc)]), [m[\"cls\"] for m in Mc])\n",
|
|
"\n",
|
|
"# 2) 시험: 조건 10종 x 4클래스 x 15장 = 600장\n",
|
|
"Ote, Ste, Mte = make_dataset(600, seed=4, scen=ALL)\n",
|
|
"t0 = time.perf_counter()\n",
|
|
"Pte = prep(Ote, Ste, Mte)\n",
|
|
"pa = adv.predict(np.array([adv_features(o, s) for o, s, _ in Pte]))\n",
|
|
"lat = (time.perf_counter() - t0) / len(Pte) * 1000\n",
|
|
"pb = base.predict(np.array([conv_features(p[0]) for p in Pte]))\n",
|
|
"y, sc = np.array([m[\"cls\"] for m in Mte]), np.array([m[\"scen\"] for m in Mte])\n",
|
|
"mil = np.isin(y, list(MILITARY))\n",
|
|
"rec = lambda p, sel: ((p == y) & mil & sel).sum() / (mil & sel).sum() # 군용 장비를 '정확한 기종'으로 식별한 비율\n",
|
|
"allsel = np.ones(len(y), bool)\n",
|
|
"print(f\"군용 장비 기종 식별 재현율: Baseline {rec(pb, allsel):.1%} -> 정밀 모델 {rec(pa, allsel):.1%} (목표 85% 이상: {'달성' if rec(pa, allsel) >= 0.85 else '미달'})\")\n",
|
|
"print(f\"4클래스 정확도: Baseline {(pb == y).mean():.1%} -> 정밀 모델 {(pa == y).mean():.1%}, 1장당 처리 {lat:.1f} ms (목표 200 ms 이하)\")\n",
|
|
"\n",
|
|
"# 3) 은폐 조건 10종 시험(군용 기종 식별 재현율 85% 이상이면 통과)\n",
|
|
"rows = []\n",
|
|
"for k in ALL:\n",
|
|
" s = sc == k\n",
|
|
" rows.append({\"조건\": k, \"Baseline 재현율\": round(rec(pb, s), 2), \"정밀 재현율\": round(rec(pa, s), 2),\n",
|
|
" \"정밀 정확도\": round(float((pa[s] == y[s]).mean()), 2), \"민간->군용 오분류\": int((~mil & s & np.isin(pa, list(MILITARY))).sum()),\n",
|
|
" \"판정\": \"통과\" if rec(pa, s) >= 0.85 else \"미통과\"})\n",
|
|
"t = pd.DataFrame(rows)\n",
|
|
"print(\"\\n\" + t.to_string(index=False))\n",
|
|
"print(f\"통과 {int((t['판정'] == '통과').sum())}/10종\")\n",
|
|
"\n",
|
|
"# 4) 오경보 유발 패턴과 중요 특징\n",
|
|
"print(\"\\n[혼동이 많은 쌍(실제->예측)]\", pd.Series([f\"{a}->{b}\" for a, b in zip(y, pa) if a != b]).value_counts().head(4).to_dict())\n",
|
|
"names = [\"obj_len\", \"body_len\", \"thin_cols\", \"max_width\", \"turret_pos\", \"area\", \"barrel_len\", \"fill\"] + [f\"hog{i}\" for i in range(128)] + [\"sar_mean\", \"sar_max\", \"sar>0.25\", \"sar>0.4\", \"sar_width\", \"sar_len\"] + \\\n",
|
|
" [f\"sar_colprof{i}\" for i in range(8)] + [f\"sar_rowprof{i}\" for i in range(8)] + [f\"opt_colprof{i}\" for i in range(8)] + \\\n",
|
|
" [f\"opt_rowprof{i}\" for i in range(8)] + [\"opt_mean\", \"opt_std\", \"opt_contrast\", \"bright_frac\", \"dark_frac\"]\n",
|
|
"imp = pd.Series(adv.feature_importances_, index=names).sort_values(ascending=False)\n",
|
|
"print(\"중요 특징 상위 5:\", {k: round(v, 3) for k, v in imp.head(5).items()})\n",
|
|
"print(\"-> SAR 반사 특징이 위장막·구름에 강해 재현율을 지탱한다. 남은 오류는 위 혼동 쌍처럼 비슷한 크기 기종 간 혼동이며 미션 5에서 신뢰도로 거른다.\")\n",
|
|
"\n",
|
|
"fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n",
|
|
"x = np.arange(len(ALL))\n",
|
|
"ax[0].bar(x - 0.2, t[\"Baseline 재현율\"], 0.4, label=\"baseline\"); ax[0].bar(x + 0.2, t[\"정밀 재현율\"], 0.4, label=\"advanced\")\n",
|
|
"ax[0].axhline(0.85, ls=\"--\", c=\"k\"); ax[0].set_xticks(x); ax[0].set_xticklabels(ALL, rotation=45, fontsize=8)\n",
|
|
"ax[0].set_ylabel(\"military recall\"); ax[0].legend(); ax[0].set_title(\"Recall by concealment scenario\")\n",
|
|
"imp.head(10)[::-1].plot.barh(ax=ax[1]); ax[1].set_title(\"Top feature importances\")\n",
|
|
"plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"군용 장비 기종 식별 재현율: Baseline 66.0% -> 정밀 모델 97.3% (목표 85% 이상: 달성)\n",
|
|
"4클래스 정확도: Baseline 69.2% -> 정밀 모델 98.0%, 1장당 처리 1.5 ms (목표 200 ms 이하)\n",
|
|
"\n",
|
|
" 조건 Baseline 재현율 정밀 재현율 정밀 정확도 민간->군용 오분류 판정\n",
|
|
" clean 1.00 0.98 0.98 0 통과\n",
|
|
" camo 0.33 0.96 0.97 0 통과\n",
|
|
" occl30 0.73 1.00 1.00 0 통과\n",
|
|
" occl50 0.53 0.98 0.98 0 통과\n",
|
|
" cloud 0.33 1.00 1.00 0 통과\n",
|
|
" low_sun 0.82 0.96 0.97 0 통과\n",
|
|
"off_nadir 0.98 1.00 1.00 0 통과\n",
|
|
" low_res 0.33 0.87 0.90 0 통과\n",
|
|
" speckle 1.00 1.00 1.00 0 통과\n",
|
|
"camo_occl 0.53 1.00 1.00 0 통과\n",
|
|
"통과 10/10종\n",
|
|
"\n",
|
|
"[혼동이 많은 쌍(실제->예측)] {'truck->tank': 6, 'tank->truck': 5, 'tank->spg': 1}\n",
|
|
"중요 특징 상위 5: {'sar>0.25': 0.083, 'sar_width': 0.08, 'sar_len': 0.07, 'sar_colprof2': 0.064, 'sar_colprof4': 0.057}\n",
|
|
"-> SAR 반사 특징이 위장막·구름에 강해 재현율을 지탱한다. 남은 오류는 위 혼동 쌍처럼 비슷한 크기 기종 간 혼동이며 미션 5에서 신뢰도로 거른다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 05. 위성 촬영 각도/그림자 영향에 따른 객체분류 신뢰도 점수 산출 및 오분류 필터 구현\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"위성 센서의 해상도 한계 및 기상 영향을 반영하여 장비 분류 결과에 대한 신뢰도 점수를 계산하고 오분류를 필터링하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 해상도 한계, 촬영 각도, 태양 고도(그림자), 구름 영향을 반영해 분류 결과의 신뢰도 점수를 계산하고 오분류를 걸러내는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **분류 확률의 최댓값·차이·엔트로피와 구름 비율, 해상도, 촬영 각도, 태양 고도, SAR 반사 세기를 품질 지표로 삼아 별도 보정 자료에서 '분류가 맞을 확률'을 학습하고 그 계수를 신뢰도 가중치 매트릭스로 공개 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/verification/isr01_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": [
|
|
"\"\"\"위성 센서 해상도 한계, 비스듬 촬영 각도, 태양 고도(그림자), 구름 차폐를 반영해 장비 분류 결과의 신뢰도 점수(분류가 맞을 확률)와 등급(High/Medium/Low)을 계산하고, 낮은 신뢰도 결과를 판독 보류로 돌려 오분류 보고를 줄이는 엔진을 개발한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"from sklearn.linear_model import LogisticRegression\n",
|
|
"from sklearn.metrics import roc_auc_score\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n",
|
|
"# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"N = 32\n",
|
|
"STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n",
|
|
"CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n",
|
|
"MILITARY = {\"tank\", \"spg\", \"truck\"}\n",
|
|
"# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n",
|
|
"SHAPES = {\n",
|
|
" \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n",
|
|
" \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n",
|
|
" \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n",
|
|
" \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n",
|
|
" \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n",
|
|
" \"none\": [], # 객체 없는 배경 타일\n",
|
|
"}\n",
|
|
"SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n",
|
|
"\n",
|
|
"\n",
|
|
"def _mask(cls, angle, scale, squash=1.0):\n",
|
|
" \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n",
|
|
" yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
" yy = yy / squash\n",
|
|
" a = np.radians(angle)\n",
|
|
" u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n",
|
|
" v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n",
|
|
" return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_chip(cls, rng, cond=None):\n",
|
|
" \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n",
|
|
" c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n",
|
|
" gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n",
|
|
" scale = STD_GSD / gsd\n",
|
|
" angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n",
|
|
" squash = np.cos(np.radians(off))\n",
|
|
" sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n",
|
|
" sun_az = rng.uniform(0, 360)\n",
|
|
" bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n",
|
|
" parts = _mask(cls, angle, scale, squash)\n",
|
|
" body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n",
|
|
" opt = bg.copy()\n",
|
|
" # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n",
|
|
" L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n",
|
|
" sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n",
|
|
" opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n",
|
|
" if cls == \"civil\":\n",
|
|
" base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n",
|
|
" opt[body] = base + rng.normal(0, 8, body.sum())\n",
|
|
" roof = parts[1][0]\n",
|
|
" opt[roof] = base * 0.8\n",
|
|
" else:\n",
|
|
" ref = bg[body].mean() if body.any() else bg.mean()\n",
|
|
" for m, d in parts:\n",
|
|
" opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n",
|
|
" # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n",
|
|
" if c[\"camo\"] > 0 and cls != \"civil\":\n",
|
|
" net = ndimage.binary_dilation(body, iterations=2)\n",
|
|
" tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n",
|
|
" opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n",
|
|
" # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n",
|
|
" if c[\"occl\"] > 0:\n",
|
|
" trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n",
|
|
" near = ndimage.binary_dilation(body, iterations=3)\n",
|
|
" cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n",
|
|
" opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n",
|
|
" # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n",
|
|
" cmask = np.zeros((N, N), bool)\n",
|
|
" if c[\"cloud\"] > 0:\n",
|
|
" blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n",
|
|
" cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n",
|
|
" opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n",
|
|
" # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n",
|
|
" sar = np.full((N, N), 0.12)\n",
|
|
" sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n",
|
|
" edge = body & ~ndimage.binary_erosion(body)\n",
|
|
" sar[edge] *= 1.3 if cls in MILITARY else 1.0\n",
|
|
" sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n",
|
|
" if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n",
|
|
" down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n",
|
|
" opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n",
|
|
" meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n",
|
|
" \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n",
|
|
" \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n",
|
|
" return np.clip(opt, 0, 255), sar, meta\n",
|
|
"\n",
|
|
"\n",
|
|
"SCEN = { # 은폐·촬영 조건 시나리오 10종\n",
|
|
" \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n",
|
|
" \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n",
|
|
" \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n",
|
|
" for i in range(n):\n",
|
|
" cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n",
|
|
" O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n",
|
|
" m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n",
|
|
" M.append(m)\n",
|
|
" return O, S, M\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n",
|
|
"def _fit(a, fill):\n",
|
|
" \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n",
|
|
" out = np.full((N, N), fill, float)\n",
|
|
" h, w = a.shape\n",
|
|
" ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n",
|
|
" yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n",
|
|
" hh, ww = min(N, h), min(N, w)\n",
|
|
" out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"def cloud_mask(opt, sm, min_area=15):\n",
|
|
" \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n",
|
|
" bright = opt > 185\n",
|
|
" if bright.sum() < min_area:\n",
|
|
" return np.zeros_like(bright)\n",
|
|
" local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n",
|
|
" cand = bright & (local_sd < 12) & (sm < 0.3)\n",
|
|
" lab, n = ndimage.label(cand)\n",
|
|
" if n == 0:\n",
|
|
" return cand\n",
|
|
" sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n",
|
|
" return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n",
|
|
"\n",
|
|
"\n",
|
|
"def orientation(opt, sm):\n",
|
|
" \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n",
|
|
" m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n == 0:\n",
|
|
" return 0.0, m\n",
|
|
" c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n",
|
|
" m = lab == k\n",
|
|
" yy, xx = np.nonzero(m)\n",
|
|
" if len(yy) < 5:\n",
|
|
" return 0.0, m\n",
|
|
" dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n",
|
|
" return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n",
|
|
"\n",
|
|
"\n",
|
|
"_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
"\n",
|
|
"\n",
|
|
"def warp(stack, zy, zx, ang):\n",
|
|
" \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n",
|
|
" a = np.radians(ang)[:, None, None]\n",
|
|
" zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n",
|
|
" sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n",
|
|
" sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n",
|
|
" y0, x0 = sy.astype(int), sx.astype(int)\n",
|
|
" fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n",
|
|
" flat = stack.reshape(len(stack), stack.shape[1], -1)\n",
|
|
" lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n",
|
|
" g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n",
|
|
" out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n",
|
|
" return out.reshape(stack.shape)\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess_batch(O, S, M):\n",
|
|
" \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n",
|
|
" 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n",
|
|
" src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n",
|
|
" if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n",
|
|
" mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n",
|
|
" src = np.where(np.isnan(src), mean, src)\n",
|
|
" zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n",
|
|
" zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n",
|
|
" out = warp(src, zy, zx, np.zeros(len(M)))\n",
|
|
" sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n",
|
|
" angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n",
|
|
" for i in range(len(M)):\n",
|
|
" cm = cloud_mask(out[i, 0], sm[i])\n",
|
|
" if cm.any():\n",
|
|
" fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n",
|
|
" out[i, 0][cm] = fill\n",
|
|
" back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n",
|
|
" src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n",
|
|
" cloud[i] = cm.mean()\n",
|
|
" angs[i] = orientation(out[i, 0], sm[i])[0]\n",
|
|
" rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n",
|
|
" if rot.any():\n",
|
|
" out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n",
|
|
" return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(opt, sar, meta):\n",
|
|
" \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n",
|
|
" return preprocess_batch([opt], [sar], [meta])[0]\n",
|
|
"\n",
|
|
"# ---- 특징 추출 공통 함수 ----\n",
|
|
"KERNELS = [np.array(k, float) for k in (\n",
|
|
" [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n",
|
|
" [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n",
|
|
"\n",
|
|
"\n",
|
|
"def conv_features(o):\n",
|
|
" \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n",
|
|
" feats = []\n",
|
|
" for k in KERNELS:\n",
|
|
" r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n",
|
|
" feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" return np.concatenate(feats)\n",
|
|
"\n",
|
|
"\n",
|
|
"def hog(o, cell=8, bins=8):\n",
|
|
" gy, gx = np.gradient(o)\n",
|
|
" mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n",
|
|
" b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n",
|
|
" out = np.zeros((N // cell, N // cell, bins))\n",
|
|
" for i in range(N // cell):\n",
|
|
" for j in range(N // cell):\n",
|
|
" sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n",
|
|
" out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n",
|
|
" return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n",
|
|
"\n",
|
|
"\n",
|
|
"def shape_features(o, s):\n",
|
|
" \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n",
|
|
" 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" m = (sm > 0.22) | (dark > 0.12)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n:\n",
|
|
" c = lab[10:22, 8:24]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n",
|
|
" m = lab == k\n",
|
|
" p = m.sum(axis=0).astype(float)\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" if len(cols) == 0:\n",
|
|
" return np.zeros(8), o, s\n",
|
|
" thin = (p > 0) & (p <= 2)\n",
|
|
" left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n",
|
|
" if left > right:\n",
|
|
" o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" body = np.nonzero(p > 3)[0]\n",
|
|
" d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n",
|
|
" heavy = d.sum(axis=0)\n",
|
|
" centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n",
|
|
" turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n",
|
|
" return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n",
|
|
" m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n",
|
|
"\n",
|
|
"\n",
|
|
"def adv_features(o, s):\n",
|
|
" \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n",
|
|
" shp, o, s = shape_features(o, s)\n",
|
|
" c = s[8:24, 4:28]\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" m = sm > 0.2\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n",
|
|
" sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n",
|
|
"\n",
|
|
"Q_NAMES = [\"max_prob\", \"margin\", \"entropy\", \"cloud_frac\", \"gsd\", \"off_nadir\", \"sun_el\", \"sar_mean\"]\n",
|
|
"\n",
|
|
"\n",
|
|
"def run(O, S, M, clf):\n",
|
|
" P = preprocess_batch(O, S, M)\n",
|
|
" X = np.array([adv_features(o, s) for o, s, _ in P])\n",
|
|
" pr = clf.predict_proba(X)\n",
|
|
" srt = np.sort(pr, axis=1)\n",
|
|
" ent = -(pr * np.log(np.clip(pr, 1e-9, 1))).sum(1) / np.log(pr.shape[1])\n",
|
|
" Q = np.c_[srt[:, -1], srt[:, -1] - srt[:, -2], ent, [p[2] for p in P], [m[\"gsd\"] for m in M],\n",
|
|
" [m[\"off_nadir\"] for m in M], [m[\"sun_el\"] for m in M], [p[1][8:24, 4:28].mean() for p in P]]\n",
|
|
" return clf.classes_[pr.argmax(1)], Q\n",
|
|
"\n",
|
|
"\n",
|
|
"def grade(c):\n",
|
|
" return np.where(c >= 0.9, \"High\", np.where(c >= 0.6, \"Medium\", \"Low\"))\n",
|
|
"\n",
|
|
"\n",
|
|
"ALL = tuple(SCEN)\n",
|
|
"Otr, Str, Mtr = make_dataset(1200, seed=3, scen=ALL)\n",
|
|
"clf = RandomForestClassifier(n_estimators=200, min_samples_leaf=2, random_state=0, n_jobs=1)\n",
|
|
"clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n",
|
|
"\n",
|
|
"# 1) 신뢰도 가중치 학습: 별도 보정 자료 600장에서 '분류가 맞았는가'를 품질 지표로 예측\n",
|
|
"Oc, Sc, Mc = make_dataset(600, seed=6, scen=ALL)\n",
|
|
"pc, Qc = run(Oc, Sc, Mc, clf)\n",
|
|
"conf_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Qc, pc == np.array([m[\"cls\"] for m in Mc]))\n",
|
|
"w = pd.Series(conf_model[-1].coef_[0], index=Q_NAMES).round(2)\n",
|
|
"print(\"[신뢰도 가중치(표준화 계수)]\", w.to_dict())\n",
|
|
"\n",
|
|
"# 2) 시험 800장: 기존(분류 결과 그대로 보고) vs 엔진(High만 자동 보고, 나머지는 판독 보류)\n",
|
|
"Ot, St, Mt = make_dataset(800, seed=5, scen=ALL)\n",
|
|
"pt, Qt = run(Ot, St, Mt, clf)\n",
|
|
"y = np.array([m[\"cls\"] for m in Mt])\n",
|
|
"conf = conf_model.predict_proba(Qt)[:, 1]\n",
|
|
"G = grade(conf)\n",
|
|
"wrong = pt != y\n",
|
|
"rep0, rep1 = np.isin(pt, list(MILITARY)), np.isin(pt, list(MILITARY)) & (G == \"High\")\n",
|
|
"far0, far1 = (wrong & rep0).sum() / rep0.sum(), (wrong & rep1).sum() / rep1.sum()\n",
|
|
"print(f\"\\n기존: 군용 보고 {rep0.sum()}건 중 오분류 {(wrong & rep0).sum()}건 (오분류율 {far0:.1%})\")\n",
|
|
"print(f\"엔진: 군용 자동 보고(High) {rep1.sum()}건 중 오분류 {(wrong & rep1).sum()}건 (오분류율 {far1:.1%}), 판독 보류(Medium·Low) {(rep0 & ~rep1).sum()}건\")\n",
|
|
"cut = 1 - far1 / far0\n",
|
|
"print(f\"오분류(오경보) 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC(신뢰도 vs 정답 여부) {roc_auc_score(~wrong, conf):.3f}\")\n",
|
|
"\n",
|
|
"# 3) 등급별 실제 정답률과 임계값 민감도\n",
|
|
"sc = np.array([m[\"scen\"] for m in Mt])\n",
|
|
"print(\"\\n등급 건수 실제 정답률 주요 조건\")\n",
|
|
"for g in [\"High\", \"Medium\", \"Low\"]:\n",
|
|
" s = G == g\n",
|
|
" print(f\"{g:6s} {s.sum():4d} {(~wrong[s]).mean() if s.any() else 0:9.1%} {pd.Series(sc[s]).value_counts().head(2).to_dict()}\")\n",
|
|
"print(\"\\nHigh 기준 자동보고 오분류율 판독 보류\")\n",
|
|
"for t in [0.8, 0.85, 0.9, 0.95]:\n",
|
|
" r = rep0 & (conf >= t)\n",
|
|
" print(f\" {t:.2f} {r.sum():6d} {(wrong & r).sum() / max(r.sum(), 1):6.1%} {(rep0 & ~r).sum():6d}\")\n",
|
|
"\n",
|
|
"# 4) 일관성: 같은 영상에 미세한 픽셀 잡음(+-1%)을 넣어도 같은 등급이 나오는 비율\n",
|
|
"rng = np.random.default_rng(0)\n",
|
|
"sub = np.arange(200)\n",
|
|
"_, Qn = run(Ot[sub] * (1 + rng.normal(0, 0.01, Ot[sub].shape)), St[sub], [Mt[i] for i in sub], clf)\n",
|
|
"same = (grade(conf_model.predict_proba(Qn)[:, 1]) == G[sub]).mean()\n",
|
|
"print(f\"\\n신뢰도 등급 산출 일관성 {same:.1%} (목표 90% 이상: {'달성' if same >= 0.9 else '미달'})\")\n",
|
|
"print(\"High도 최종 판독이 아니며, 고가치 표적은 미션 7의 영상분석관 판독을 거친다.\")\n",
|
|
"\n",
|
|
"fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n",
|
|
"ax[0].hist([conf[~wrong], conf[wrong]], bins=20, range=(0, 1), label=[\"correct\", \"misclassified\"], stacked=True)\n",
|
|
"ax[0].axvline(0.9, ls=\"--\", c=\"k\"); ax[0].set_xlabel(\"confidence score\"); ax[0].legend(); ax[0].set_title(\"Confidence vs correctness\")\n",
|
|
"w.plot.barh(ax=ax[1], color=[\"tab:green\" if v > 0 else \"tab:red\" for v in w]); ax[1].set_title(\"Confidence weight matrix (std. coef)\")\n",
|
|
"plt.tight_layout(); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[신뢰도 가중치(표준화 계수)] {'max_prob': 0.69, 'margin': 0.81, 'entropy': -0.62, 'cloud_frac': 0.2, 'gsd': -0.06, 'off_nadir': -0.54, 'sun_el': -0.52, 'sar_mean': 0.24}\n",
|
|
"\n",
|
|
"기존: 군용 보고 600건 중 오분류 19건 (오분류율 3.2%)\n",
|
|
"엔진: 군용 자동 보고(High) 541건 중 오분류 3건 (오분류율 0.6%), 판독 보류(Medium·Low) 59건\n",
|
|
"오분류(오경보) 감축 82.5% (목표 40% 이상: 달성), ROC-AUC(신뢰도 vs 정답 여부) 0.961\n",
|
|
"\n",
|
|
"등급 건수 실제 정답률 주요 조건\n",
|
|
"High 741 99.6% {'occl50': 79, 'clean': 78}\n",
|
|
"Medium 38 84.2% {'low_res': 13, 'occl30': 5}\n",
|
|
"Low 21 52.4% {'low_res': 9, 'speckle': 4}\n",
|
|
"\n",
|
|
"High 기준 자동보고 오분류율 판독 보류\n",
|
|
" 0.80 555 1.1% 45\n",
|
|
" 0.85 552 0.9% 48\n",
|
|
" 0.90 541 0.6% 59\n",
|
|
" 0.95 521 0.6% 79\n",
|
|
"\n",
|
|
"신뢰도 등급 산출 일관성 98.5% (목표 90% 이상: 달성)\n",
|
|
"High도 최종 판독이 아니며, 고가치 표적은 미션 7의 영상분석관 판독을 거친다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 06. 위성영상 표적 탐지 위치 및 식별 확률 지도(Heatmap) 가시화 UI 구현\n",
|
|
"**소요시간**: 약 2.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"식별된 장비의 위치, 기종, 신뢰도 및 집결지 밀집도를 지도상에 오버레이 표시하는 위성 ISR 영상분석 인터페이스를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **식별된 장비의 위치·기종·식별 확률과 집결지 밀집도를 지도에 겹쳐 보여 주는 위성 ISR 영상분석 화면을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **가상 광역 영상(타일 10x10)을 한 줄씩 받아 판독하는 갱신 흐름을 모의하고 처리시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/ui/isr01_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": [
|
|
"\"\"\"가상 광역 위성영상(32x32 타일 10x10)을 타일 단위로 판독해 식별 장비의 위치·기종·식별 확률을 지도에 겹쳐 표시하고, 군용 장비 집결지 밀집도(Heatmap)와 기종별 현황을 함께 보여 주는 위성 ISR 영상분석 화면을 구현하며 갱신 처리시간과 렌더링 시간을 측정한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n",
|
|
"# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"N = 32\n",
|
|
"STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n",
|
|
"CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n",
|
|
"MILITARY = {\"tank\", \"spg\", \"truck\"}\n",
|
|
"# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n",
|
|
"SHAPES = {\n",
|
|
" \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n",
|
|
" \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n",
|
|
" \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n",
|
|
" \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n",
|
|
" \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n",
|
|
" \"none\": [], # 객체 없는 배경 타일\n",
|
|
"}\n",
|
|
"SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n",
|
|
"\n",
|
|
"\n",
|
|
"def _mask(cls, angle, scale, squash=1.0):\n",
|
|
" \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n",
|
|
" yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
" yy = yy / squash\n",
|
|
" a = np.radians(angle)\n",
|
|
" u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n",
|
|
" v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n",
|
|
" return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_chip(cls, rng, cond=None):\n",
|
|
" \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n",
|
|
" c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n",
|
|
" gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n",
|
|
" scale = STD_GSD / gsd\n",
|
|
" angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n",
|
|
" squash = np.cos(np.radians(off))\n",
|
|
" sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n",
|
|
" sun_az = rng.uniform(0, 360)\n",
|
|
" bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n",
|
|
" parts = _mask(cls, angle, scale, squash)\n",
|
|
" body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n",
|
|
" opt = bg.copy()\n",
|
|
" # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n",
|
|
" L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n",
|
|
" sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n",
|
|
" opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n",
|
|
" if cls == \"civil\":\n",
|
|
" base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n",
|
|
" opt[body] = base + rng.normal(0, 8, body.sum())\n",
|
|
" roof = parts[1][0]\n",
|
|
" opt[roof] = base * 0.8\n",
|
|
" else:\n",
|
|
" ref = bg[body].mean() if body.any() else bg.mean()\n",
|
|
" for m, d in parts:\n",
|
|
" opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n",
|
|
" # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n",
|
|
" if c[\"camo\"] > 0 and cls != \"civil\":\n",
|
|
" net = ndimage.binary_dilation(body, iterations=2)\n",
|
|
" tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n",
|
|
" opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n",
|
|
" # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n",
|
|
" if c[\"occl\"] > 0:\n",
|
|
" trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n",
|
|
" near = ndimage.binary_dilation(body, iterations=3)\n",
|
|
" cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n",
|
|
" opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n",
|
|
" # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n",
|
|
" cmask = np.zeros((N, N), bool)\n",
|
|
" if c[\"cloud\"] > 0:\n",
|
|
" blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n",
|
|
" cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n",
|
|
" opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n",
|
|
" # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n",
|
|
" sar = np.full((N, N), 0.12)\n",
|
|
" sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n",
|
|
" edge = body & ~ndimage.binary_erosion(body)\n",
|
|
" sar[edge] *= 1.3 if cls in MILITARY else 1.0\n",
|
|
" sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n",
|
|
" if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n",
|
|
" down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n",
|
|
" opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n",
|
|
" meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n",
|
|
" \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n",
|
|
" \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n",
|
|
" return np.clip(opt, 0, 255), sar, meta\n",
|
|
"\n",
|
|
"\n",
|
|
"SCEN = { # 은폐·촬영 조건 시나리오 10종\n",
|
|
" \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n",
|
|
" \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n",
|
|
" \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n",
|
|
" for i in range(n):\n",
|
|
" cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n",
|
|
" O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n",
|
|
" m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n",
|
|
" M.append(m)\n",
|
|
" return O, S, M\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n",
|
|
"def _fit(a, fill):\n",
|
|
" \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n",
|
|
" out = np.full((N, N), fill, float)\n",
|
|
" h, w = a.shape\n",
|
|
" ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n",
|
|
" yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n",
|
|
" hh, ww = min(N, h), min(N, w)\n",
|
|
" out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"def cloud_mask(opt, sm, min_area=15):\n",
|
|
" \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n",
|
|
" bright = opt > 185\n",
|
|
" if bright.sum() < min_area:\n",
|
|
" return np.zeros_like(bright)\n",
|
|
" local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n",
|
|
" cand = bright & (local_sd < 12) & (sm < 0.3)\n",
|
|
" lab, n = ndimage.label(cand)\n",
|
|
" if n == 0:\n",
|
|
" return cand\n",
|
|
" sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n",
|
|
" return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n",
|
|
"\n",
|
|
"\n",
|
|
"def orientation(opt, sm):\n",
|
|
" \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n",
|
|
" m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n == 0:\n",
|
|
" return 0.0, m\n",
|
|
" c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n",
|
|
" m = lab == k\n",
|
|
" yy, xx = np.nonzero(m)\n",
|
|
" if len(yy) < 5:\n",
|
|
" return 0.0, m\n",
|
|
" dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n",
|
|
" return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n",
|
|
"\n",
|
|
"\n",
|
|
"_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
"\n",
|
|
"\n",
|
|
"def warp(stack, zy, zx, ang):\n",
|
|
" \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n",
|
|
" a = np.radians(ang)[:, None, None]\n",
|
|
" zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n",
|
|
" sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n",
|
|
" sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n",
|
|
" y0, x0 = sy.astype(int), sx.astype(int)\n",
|
|
" fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n",
|
|
" flat = stack.reshape(len(stack), stack.shape[1], -1)\n",
|
|
" lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n",
|
|
" g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n",
|
|
" out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n",
|
|
" return out.reshape(stack.shape)\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess_batch(O, S, M):\n",
|
|
" \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n",
|
|
" 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n",
|
|
" src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n",
|
|
" if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n",
|
|
" mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n",
|
|
" src = np.where(np.isnan(src), mean, src)\n",
|
|
" zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n",
|
|
" zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n",
|
|
" out = warp(src, zy, zx, np.zeros(len(M)))\n",
|
|
" sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n",
|
|
" angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n",
|
|
" for i in range(len(M)):\n",
|
|
" cm = cloud_mask(out[i, 0], sm[i])\n",
|
|
" if cm.any():\n",
|
|
" fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n",
|
|
" out[i, 0][cm] = fill\n",
|
|
" back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n",
|
|
" src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n",
|
|
" cloud[i] = cm.mean()\n",
|
|
" angs[i] = orientation(out[i, 0], sm[i])[0]\n",
|
|
" rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n",
|
|
" if rot.any():\n",
|
|
" out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n",
|
|
" return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(opt, sar, meta):\n",
|
|
" \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n",
|
|
" return preprocess_batch([opt], [sar], [meta])[0]\n",
|
|
"\n",
|
|
"# ---- 특징 추출 공통 함수 ----\n",
|
|
"KERNELS = [np.array(k, float) for k in (\n",
|
|
" [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n",
|
|
" [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n",
|
|
"\n",
|
|
"\n",
|
|
"def conv_features(o):\n",
|
|
" \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n",
|
|
" feats = []\n",
|
|
" for k in KERNELS:\n",
|
|
" r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n",
|
|
" feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" return np.concatenate(feats)\n",
|
|
"\n",
|
|
"\n",
|
|
"def hog(o, cell=8, bins=8):\n",
|
|
" gy, gx = np.gradient(o)\n",
|
|
" mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n",
|
|
" b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n",
|
|
" out = np.zeros((N // cell, N // cell, bins))\n",
|
|
" for i in range(N // cell):\n",
|
|
" for j in range(N // cell):\n",
|
|
" sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n",
|
|
" out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n",
|
|
" return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n",
|
|
"\n",
|
|
"\n",
|
|
"def shape_features(o, s):\n",
|
|
" \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n",
|
|
" 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" m = (sm > 0.22) | (dark > 0.12)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n:\n",
|
|
" c = lab[10:22, 8:24]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n",
|
|
" m = lab == k\n",
|
|
" p = m.sum(axis=0).astype(float)\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" if len(cols) == 0:\n",
|
|
" return np.zeros(8), o, s\n",
|
|
" thin = (p > 0) & (p <= 2)\n",
|
|
" left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n",
|
|
" if left > right:\n",
|
|
" o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" body = np.nonzero(p > 3)[0]\n",
|
|
" d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n",
|
|
" heavy = d.sum(axis=0)\n",
|
|
" centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n",
|
|
" turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n",
|
|
" return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n",
|
|
" m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n",
|
|
"\n",
|
|
"\n",
|
|
"def adv_features(o, s):\n",
|
|
" \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n",
|
|
" shp, o, s = shape_features(o, s)\n",
|
|
" c = s[8:24, 4:28]\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" m = sm > 0.2\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n",
|
|
" sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n",
|
|
"\n",
|
|
"T = 10 # 10x10 타일 = 320x320 픽셀 가상 장면\n",
|
|
"rng = np.random.default_rng(12)\n",
|
|
"# [교육용가상자료] 장면 구성: 가상 집결지 2곳(군용), 가상 도로(민간 차량), 나머지는 빈 타일\n",
|
|
"layout = np.full((T, T), \"none\", dtype=object)\n",
|
|
"for (r0, c0), n in [((1, 1), 9), ((6, 6), 7)]:\n",
|
|
" for _ in range(n):\n",
|
|
" layout[r0 + rng.integers(0, 3), c0 + rng.integers(0, 3)] = rng.choice([\"tank\", \"spg\", \"truck\"], p=[.4, .3, .3])\n",
|
|
"for c in rng.choice(T, 5, replace=False):\n",
|
|
" layout[4, c] = \"civil\"\n",
|
|
"scen = rng.choice([\"clean\", \"camo\", \"occl30\", \"cloud\", \"low_sun\"], (T, T), p=[.4, .2, .15, .15, .1])\n",
|
|
"tiles = {}\n",
|
|
"for r in range(T):\n",
|
|
" for c in range(T):\n",
|
|
" o, s, m = make_chip(layout[r, c], rng, SCEN[scen[r, c]])\n",
|
|
" tiles[(r, c)] = (o, s, m)\n",
|
|
"\n",
|
|
"# 판독 모델(이 파일 안에서 학습: 빈 타일 포함 5개 클래스)\n",
|
|
"Otr, Str, Mtr = make_dataset(1000, seed=3, scen=tuple(SCEN), classes=CLASSES + [\"none\"])\n",
|
|
"clf = RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1)\n",
|
|
"clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n",
|
|
"\n",
|
|
"# 1) 1초 주기 갱신 모의: 매 갱신마다 새로 들어온 타일 한 줄(10장)을 판독해 화면 자료 갱신\n",
|
|
"dets, upd = [], []\n",
|
|
"for r in range(T):\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" row = [tiles[(r, c)] for c in range(T)]\n",
|
|
" X = np.array([adv_features(o, s) for o, s, _ in preprocess_batch(*zip(*row))])\n",
|
|
" P = clf.predict_proba(X) # 한 줄을 묶어서 판독(호출 횟수 감소)\n",
|
|
" for c, p in enumerate(P):\n",
|
|
" k = int(p.argmax())\n",
|
|
" if clf.classes_[k] != \"none\":\n",
|
|
" dets.append({\"타일\": f\"R{r}C{c}\", \"x\": c * N + N / 2, \"y\": r * N + N / 2, \"기종(추정)\": clf.classes_[k],\n",
|
|
" \"식별확률\": round(float(p[k]), 2), \"실제\": layout[r, c], \"조건\": scen[r, c]})\n",
|
|
" upd.append((time.perf_counter() - t0) * 1000)\n",
|
|
"D = pd.DataFrame(dets)\n",
|
|
"print(f\"타일 한 줄(10장) 갱신 처리 평균 {np.mean(upd):.0f} ms, 최대 {np.max(upd):.0f} ms (목표: 갱신 1초 이내, 처리 응답 100 ms 이내)\")\n",
|
|
"print(f\"식별 {len(D)}건 / 실제 객체 {int((layout != 'none').sum())}건, 기종 일치 {int((D['기종(추정)'] == D['실제']).sum())}건\")\n",
|
|
"print(\"\\n[식별 목록: 식별확률 낮은 순(분석관 우선 확인)]\")\n",
|
|
"print(D.sort_values(\"식별확률\")[[\"타일\", \"기종(추정)\", \"식별확률\", \"조건\"]].head(8).to_string(index=False))\n",
|
|
"\n",
|
|
"# 2) 집결지 밀집도: 군용 식별 위치를 식별확률로 가중해 가우시안 평활\n",
|
|
"mil = D[D[\"기종(추정)\"].isin(list(MILITARY))]\n",
|
|
"grid = np.zeros((T * N, T * N))\n",
|
|
"for _, d in mil.iterrows():\n",
|
|
" grid[int(d[\"y\"]), int(d[\"x\"])] += d[\"식별확률\"]\n",
|
|
"heat = ndimage.gaussian_filter(grid, 24)\n",
|
|
"peak = np.unravel_index(heat.argmax(), heat.shape)\n",
|
|
"print(f\"\\n최대 밀집 지점(가상 픽셀 좌표) x={peak[1]}, y={peak[0]}, 군용 식별 {len(mil)}건\")\n",
|
|
"\n",
|
|
"# 3) 분석 화면(야간 작전용 어두운 화면, 4개 구성요소)\n",
|
|
"scene = np.block([[tiles[(r, c)][0] for c in range(T)] for r in range(T)])\n",
|
|
"plt.style.use(\"dark_background\")\n",
|
|
"fig, ax = plt.subplots(2, 2, figsize=(13, 11))\n",
|
|
"mk = {\"tank\": \"s\", \"spg\": \"D\", \"truck\": \"^\", \"civil\": \"o\"}\n",
|
|
"ax[0, 0].imshow(scene, cmap=\"gray\")\n",
|
|
"for k, g in D.groupby(\"기종(추정)\"):\n",
|
|
" ax[0, 0].scatter(g[\"x\"], g[\"y\"], s=40 + 200 * g[\"식별확률\"], marker=mk[k], facecolors=\"none\",\n",
|
|
" edgecolors=\"red\" if k in MILITARY else \"deepskyblue\", lw=1.5, label=k)\n",
|
|
"ax[0, 0].legend(fontsize=8, loc=\"lower right\"); ax[0, 0].set_title(\"Detections (marker size = probability)\"); ax[0, 0].axis(\"off\")\n",
|
|
"ax[0, 1].imshow(scene, cmap=\"gray\"); ax[0, 1].imshow(heat, cmap=\"inferno\", alpha=0.6); ax[0, 1].set_title(\"Military vehicle density (heatmap)\"); ax[0, 1].axis(\"off\")\n",
|
|
"vc = D[\"기종(추정)\"].value_counts()\n",
|
|
"ax[1, 0].bar(vc.index.tolist(), vc.values, color=\"deepskyblue\"); ax[1, 0].set_title(\"Detections by type\")\n",
|
|
"ax[1, 1].hist(D[\"식별확률\"], bins=10, range=(0, 1), color=\"orange\"); ax[1, 1].axvline(0.6, color=\"red\", ls=\"--\")\n",
|
|
"ax[1, 1].set_title(\"Identification probability (red line = review threshold)\")\n",
|
|
"fig.suptitle(\"ISR01 satellite imagery analysis view - synthetic training data\")\n",
|
|
"t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000\n",
|
|
"plt.tight_layout(); plt.show()\n",
|
|
"print(f\"화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(식별 오버레이·밀집도·기종 현황·식별확률 분포)\")\n",
|
|
"print(\"표시된 기종·밀집도는 판독 보조 정보이며, 고가치 표적은 영상분석관의 최종 판독(미션 7)을 거친다.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"타일 한 줄(10장) 갱신 처리 평균 33 ms, 최대 45 ms (목표: 갱신 1초 이내, 처리 응답 100 ms 이내)\n",
|
|
"식별 17건 / 실제 객체 17건, 기종 일치 17건\n",
|
|
"\n",
|
|
"[식별 목록: 식별확률 낮은 순(분석관 우선 확인)]\n",
|
|
" 타일 기종(추정) 식별확률 조건\n",
|
|
"R1C1 tank 0.80 camo\n",
|
|
"R2C2 tank 0.87 clean\n",
|
|
"R8C8 tank 0.90 clean\n",
|
|
"R3C3 spg 0.92 camo\n",
|
|
"R7C6 tank 0.94 occl30\n",
|
|
"R3C1 tank 0.94 cloud\n",
|
|
"R8C7 truck 0.95 clean\n",
|
|
"R4C0 civil 0.95 clean\n",
|
|
"\n",
|
|
"최대 밀집 지점(가상 픽셀 좌표) x=68, y=68, 군용 식별 12건\n",
|
|
"화면 렌더링 201 ms (갱신 주기 1초 이내: 달성), 시각화 구성요소 4종(식별 오버레이·밀집도·기종 현황·식별확률 분포)\n",
|
|
"표시된 기종·밀집도는 판독 보조 정보이며, 고가치 표적은 영상분석관의 최종 판독(미션 7)을 거친다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 07. 고가치 표적(HVT) 식별 시 영상분석관 최종 판독(HITL) 및 교차 검증 절차 설계\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"고가치 군용 장비 식별 시 영상 판독관이 고해상도 확대 및 교차 검증을 통해 최종 승인하는 HITL 판독 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **고가치 표적(HVT) 후보가 식별되면 영상분석관이 고해상도 확대 판독과 교차 검증을 거쳐 최종 승인하는 HITL 판독 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **모델 식별 결과는 모두 판독 대기로 등록하고 HVT 후보를 우선 정렬 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/hitl/isr01_approval_workflow.py 및 docs/isr01_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": [
|
|
"\"\"\"고가치 표적(HVT) 후보 장비가 식별되면 영상분석관이 고해상도 확대 판독과 다른 출처(SAR·다른 촬영 회차) 교차 검증을 거쳐 최종 승인·보류·반려하는 인간 개입(HITL) 판독 워크플로우를 구현한다. HVT는 2인 판독이 필요하며 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n",
|
|
"import hashlib\n",
|
|
"import json\n",
|
|
"import time\n",
|
|
"import pandas as pd\n",
|
|
"\n",
|
|
"ACTIONS = {\"APPROVE\": \"판독 승인(정보 보고)\", \"HOLD\": \"보류(추가 촬영·출처 필요)\", \"REJECT\": \"반려(오식별·모의 표적)\"}\n",
|
|
"PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n",
|
|
"HVT_TYPES = {\"spg\"} # 교육용 가정: 자주포 식별을 고가치 표적 후보로 취급\n",
|
|
"HOLD_LIMIT_S = 3600\n",
|
|
"\n",
|
|
"\n",
|
|
"class WorkflowError(ValueError):\n",
|
|
" pass\n",
|
|
"\n",
|
|
"\n",
|
|
"class ReadingQueue:\n",
|
|
" def __init__(self, clock=time.time):\n",
|
|
" self.items, self.log, self.clock = {}, [], clock\n",
|
|
"\n",
|
|
" def _record(self, body):\n",
|
|
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
|
|
" body = {**body, \"ts\": round(self.clock(), 3)}\n",
|
|
" h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n",
|
|
" self.log.append({**body, \"prev\": prev, \"hash\": h})\n",
|
|
"\n",
|
|
" def verify_log(self):\n",
|
|
" prev = \"GENESIS\"\n",
|
|
" for n, rec in enumerate(self.log, start=1):\n",
|
|
" body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n",
|
|
" if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n",
|
|
" return False, n\n",
|
|
" prev = rec[\"hash\"]\n",
|
|
" return True, -1\n",
|
|
"\n",
|
|
" def submit(self, iid, pred, grade, conf):\n",
|
|
" \"\"\"모델 식별 결과 등록: 항상 PENDING. 기종이 HVT 후보면 2인 판독 대상으로 표시한다.\"\"\"\n",
|
|
" self.items[iid] = {\"id\": iid, \"pred\": pred, \"grade\": grade, \"conf\": conf, \"hvt\": pred in HVT_TYPES, \"status\": \"PENDING\",\n",
|
|
" \"submitted\": self.clock(), \"zoom\": False, \"cross\": None, \"first\": None, \"escalated\": False, \"hold_since\": None}\n",
|
|
" self._record({\"event\": \"SUBMIT\", \"id\": iid, \"pred\": pred, \"grade\": grade, \"by\": \"MODEL\"})\n",
|
|
"\n",
|
|
" def zoom_review(self, iid, analyst, note):\n",
|
|
" self.items[iid][\"zoom\"] = True\n",
|
|
" self._record({\"event\": \"ZOOM_REVIEW\", \"id\": iid, \"analyst\": analyst, \"note\": note})\n",
|
|
"\n",
|
|
" def cross_check(self, iid, analyst, source, agrees):\n",
|
|
" self.items[iid][\"cross\"] = bool(agrees)\n",
|
|
" self._record({\"event\": \"CROSS_CHECK\", \"id\": iid, \"analyst\": analyst, \"source\": source, \"agrees\": bool(agrees)})\n",
|
|
"\n",
|
|
" def pending(self):\n",
|
|
" todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"APPROVE_1ST\")]\n",
|
|
" return sorted(todo, key=lambda i: (not i[\"escalated\"], not i[\"hvt\"], PRIORITY[i[\"grade\"]], -i[\"conf\"]))\n",
|
|
"\n",
|
|
" def decide(self, iid, action, analyst, reason):\n",
|
|
" it = self.items.get(iid)\n",
|
|
" if action not in ACTIONS:\n",
|
|
" raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n",
|
|
" if not analyst or analyst.upper() in (\"AI\", \"MODEL\", \"SYSTEM\"):\n",
|
|
" raise WorkflowError(\"영상분석관 ID 필요(자동 판독 확정 금지)\")\n",
|
|
" if it is None or it[\"status\"] in (\"APPROVED\", \"REJECTED\"):\n",
|
|
" raise WorkflowError(\"대기열에 없거나 이미 종결된 건\")\n",
|
|
" if len(reason.strip()) < (15 if it[\"hvt\"] else 8):\n",
|
|
" raise WorkflowError(\"판독 근거 부족(HVT는 15자 이상)\")\n",
|
|
" if action == \"APPROVE\":\n",
|
|
" if not it[\"zoom\"]:\n",
|
|
" raise WorkflowError(\"고해상도 확대 판독 기록 없이 승인 불가\")\n",
|
|
" if it[\"hvt\"] and it[\"cross\"] is not True:\n",
|
|
" raise WorkflowError(\"HVT는 다른 출처 교차 검증 일치 후에만 승인 가능\")\n",
|
|
" if it[\"hvt\"] and it[\"first\"] is None:\n",
|
|
" it[\"first\"], it[\"status\"] = analyst, \"APPROVE_1ST\"\n",
|
|
" self._record({\"event\": \"APPROVE_1ST\", \"id\": iid, \"analyst\": analyst, \"reason\": reason})\n",
|
|
" return\n",
|
|
" if it[\"hvt\"] and it[\"first\"] == analyst:\n",
|
|
" raise WorkflowError(\"HVT 2인 판독: 1차 판독관과 다른 분석관 필요\")\n",
|
|
" it[\"status\"] = {\"APPROVE\": \"APPROVED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n",
|
|
" it[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n",
|
|
" it[\"decided\"] = self.clock()\n",
|
|
" self._record({\"event\": \"DECIDE\", \"id\": iid, \"action\": action, \"analyst\": analyst, \"reason\": reason})\n",
|
|
"\n",
|
|
" def check_holds(self):\n",
|
|
" \"\"\"보류 기한 초과 시 자동 승인하지 않고 선임 판독관에게 상신한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for i in self.items.values():\n",
|
|
" if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n",
|
|
" i[\"escalated\"] = True; out.append(i[\"id\"])\n",
|
|
" self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\", \"to\": \"선임 판독관\"})\n",
|
|
" return out\n",
|
|
"\n",
|
|
" def feedback(self, iid, commander, text):\n",
|
|
" self._record({\"event\": \"FEEDBACK\", \"id\": iid, \"commander\": commander, \"text\": text})\n",
|
|
"\n",
|
|
"\n",
|
|
"class FakeClock:\n",
|
|
" t = 1000.0\n",
|
|
"\n",
|
|
" def __call__(self):\n",
|
|
" return self.t\n",
|
|
"\n",
|
|
"\n",
|
|
"clock = FakeClock()\n",
|
|
"q = ReadingQueue(clock)\n",
|
|
"# [교육용가상자료] 미션 5 엔진에서 넘어온 식별 결과 5건\n",
|
|
"for iid, pred, g, c in [(\"IMG0101\", \"spg\", \"High\", 0.97), (\"IMG0102\", \"tank\", \"High\", 0.95), (\"IMG0103\", \"spg\", \"Medium\", 0.71),\n",
|
|
" (\"IMG0104\", \"truck\", \"Low\", 0.48), (\"IMG0105\", \"tank\", \"Medium\", 0.66)]:\n",
|
|
" q.submit(iid, pred, g, c)\n",
|
|
"print(\"판독 순서(HVT 우선):\", [i[\"id\"] for i in q.pending()])\n",
|
|
"print(\"자동 승인된 건수:\", sum(i[\"status\"] == \"APPROVED\" for i in q.items.values()))\n",
|
|
"\n",
|
|
"for args in [(\"IMG0101\", \"APPROVE\", \"MODEL\", \"신뢰도 0.97로 자동 승인 시도\"), (\"IMG0102\", \"APPROVE\", \"ia01\", \"포탑 중앙 배치 확인함\"),\n",
|
|
" (\"IMG0104\", \"DELETE\", \"ia01\", \"허용되지 않은 조치 시도\")]:\n",
|
|
" try:\n",
|
|
" q.decide(*args)\n",
|
|
" except WorkflowError as e:\n",
|
|
" print(\"차단:\", e)\n",
|
|
"\n",
|
|
"clock.t += 120; q.zoom_review(\"IMG0101\", \"ia01\", \"후방 대형 포탑·긴 포신 윤곽 확인\")\n",
|
|
"try:\n",
|
|
" q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"확대 판독 결과 후방 포탑과 긴 포신 일치\")\n",
|
|
"except WorkflowError as e:\n",
|
|
" print(\"차단:\", e)\n",
|
|
"q.cross_check(\"IMG0101\", \"ia01\", \"SAR 동일 지점(가상 회차 2)\", True)\n",
|
|
"clock.t += 60; q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"확대 판독 결과 후방 포탑과 긴 포신 일치, SAR 금속 반사 일치\")\n",
|
|
"try:\n",
|
|
" q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"같은 판독관의 2차 승인 시도입니다\")\n",
|
|
"except WorkflowError as e:\n",
|
|
" print(\"차단:\", e)\n",
|
|
"clock.t += 300; q.decide(\"IMG0101\", \"APPROVE\", \"ia02\", \"독립 판독 결과 동의, 위치 오차 범위 내 동일 객체로 판단\")\n",
|
|
"q.zoom_review(\"IMG0103\", \"ia02\", \"포신 길이 불명확\"); q.cross_check(\"IMG0103\", \"ia02\", \"SAR(가상 회차 2)\", False)\n",
|
|
"q.decide(\"IMG0103\", \"HOLD\", \"ia02\", \"SAR 반사 약함, 모의 표적 가능성 확인 필요\")\n",
|
|
"clock.t += 200; q.zoom_review(\"IMG0102\", \"ia01\", \"포탑 중앙 배치\"); q.decide(\"IMG0102\", \"APPROVE\", \"ia01\", \"포탑 중앙, 짧은 포신 확인\")\n",
|
|
"clock.t += 100; q.decide(\"IMG0104\", \"REJECT\", \"ia02\", \"구름 경계 오식별\")\n",
|
|
"clock.t += 4000\n",
|
|
"print(\"보류 기한 초과 상신:\", q.check_holds())\n",
|
|
"q.feedback(\"IMG0101\", \"cmd01\", \"다음 촬영 회차에서 이동 여부 재확인 요청\")\n",
|
|
"\n",
|
|
"df = pd.DataFrame(q.items.values())\n",
|
|
"df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0)\n",
|
|
"print(\"\\n\" + df[[\"id\", \"pred\", \"grade\", \"hvt\", \"status\", \"cross\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n",
|
|
"ok, _ = q.verify_log()\n",
|
|
"print(f\"\\n감사 로그 {len(q.log)}건(등록·확대판독·교차검증·결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}\")\n",
|
|
"q.log[5][\"analyst\"] = \"ia99\"\n",
|
|
"print(\"6번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n",
|
|
"print(\"\\n승인은 '판독 결과 보고' 단계까지이며, 이후 활용 판단은 지휘계통의 별도 검토를 따른다.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"판독 순서(HVT 우선): ['IMG0101', 'IMG0103', 'IMG0102', 'IMG0105', 'IMG0104']\n",
|
|
"자동 승인된 건수: 0\n",
|
|
"차단: 영상분석관 ID 필요(자동 판독 확정 금지)\n",
|
|
"차단: 고해상도 확대 판독 기록 없이 승인 불가\n",
|
|
"차단: 허용되지 않은 조치: DELETE\n",
|
|
"차단: HVT는 다른 출처 교차 검증 일치 후에만 승인 가능\n",
|
|
"차단: HVT 2인 판독: 1차 판독관과 다른 분석관 필요\n",
|
|
"보류 기한 초과 상신: ['IMG0103']\n",
|
|
"\n",
|
|
" id pred grade hvt status cross escalated 대기시간(s)\n",
|
|
"IMG0101 spg High True APPROVED True False 480.0\n",
|
|
"IMG0102 tank High False APPROVED - False 680.0\n",
|
|
"IMG0103 spg Medium True HOLD False True 480.0\n",
|
|
"IMG0104 truck Low False REJECTED - False 780.0\n",
|
|
"IMG0105 tank Medium False PENDING - False -\n",
|
|
"\n",
|
|
"감사 로그 17건(등록·확대판독·교차검증·결정·상신·피드백), 로깅 성공률 100%, 해시 체인 검증: True\n",
|
|
"6번째 기록 변조 후 검증(False, 깨진 위치): (False, 6)\n",
|
|
"\n",
|
|
"승인은 '판독 결과 보고' 단계까지이며, 이후 활용 판단은 지휘계통의 별도 검토를 따른다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 08. 악기상 및 저해상도 위성영상 대량 배치 처리 스트레스 테스트 및 한계치 도출\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"대면적 위성영상 타일 대량 배치 처리 시뮬레이션을 수행하여 시스템의 추론 병목 및 처리 한계치를 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대면적 위성영상 타일을 대량 배치로 처리해 추론 병목과 처리 한계치, 기능 축소 작동을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **평시 대비 500% 증가한 타일, 악기상·저해상도 타일, 손상 타일(잘림·전송 오류·빈 영상) 혼입, 처리 예산 부족 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `tests/isr01_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": [
|
|
"\"\"\"대면적 위성영상 타일을 대량 배치로 주입해 평시 대비 500% 증가 부하, 악기상·저해상도 타일, 손상 타일 혼입, 처리 시간 예산 부족 상황에서 시스템 다운 여부·데이터 유실률·처리량·메모리 안정성·병목 구간·기능 축소 모드를 검증하고 처리 한계치를 도출한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import tracemalloc\n",
|
|
"from collections import deque\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n",
|
|
"# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"N = 32\n",
|
|
"STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n",
|
|
"CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n",
|
|
"MILITARY = {\"tank\", \"spg\", \"truck\"}\n",
|
|
"# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n",
|
|
"SHAPES = {\n",
|
|
" \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n",
|
|
" \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n",
|
|
" \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n",
|
|
" \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n",
|
|
" \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n",
|
|
" \"none\": [], # 객체 없는 배경 타일\n",
|
|
"}\n",
|
|
"SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n",
|
|
"\n",
|
|
"\n",
|
|
"def _mask(cls, angle, scale, squash=1.0):\n",
|
|
" \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n",
|
|
" yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
" yy = yy / squash\n",
|
|
" a = np.radians(angle)\n",
|
|
" u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n",
|
|
" v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n",
|
|
" return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_chip(cls, rng, cond=None):\n",
|
|
" \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n",
|
|
" c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n",
|
|
" gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n",
|
|
" scale = STD_GSD / gsd\n",
|
|
" angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n",
|
|
" squash = np.cos(np.radians(off))\n",
|
|
" sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n",
|
|
" sun_az = rng.uniform(0, 360)\n",
|
|
" bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n",
|
|
" parts = _mask(cls, angle, scale, squash)\n",
|
|
" body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n",
|
|
" opt = bg.copy()\n",
|
|
" # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n",
|
|
" L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n",
|
|
" sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n",
|
|
" opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n",
|
|
" if cls == \"civil\":\n",
|
|
" base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n",
|
|
" opt[body] = base + rng.normal(0, 8, body.sum())\n",
|
|
" roof = parts[1][0]\n",
|
|
" opt[roof] = base * 0.8\n",
|
|
" else:\n",
|
|
" ref = bg[body].mean() if body.any() else bg.mean()\n",
|
|
" for m, d in parts:\n",
|
|
" opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n",
|
|
" # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n",
|
|
" if c[\"camo\"] > 0 and cls != \"civil\":\n",
|
|
" net = ndimage.binary_dilation(body, iterations=2)\n",
|
|
" tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n",
|
|
" opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n",
|
|
" # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n",
|
|
" if c[\"occl\"] > 0:\n",
|
|
" trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n",
|
|
" near = ndimage.binary_dilation(body, iterations=3)\n",
|
|
" cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n",
|
|
" opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n",
|
|
" # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n",
|
|
" cmask = np.zeros((N, N), bool)\n",
|
|
" if c[\"cloud\"] > 0:\n",
|
|
" blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n",
|
|
" cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n",
|
|
" opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n",
|
|
" # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n",
|
|
" sar = np.full((N, N), 0.12)\n",
|
|
" sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n",
|
|
" edge = body & ~ndimage.binary_erosion(body)\n",
|
|
" sar[edge] *= 1.3 if cls in MILITARY else 1.0\n",
|
|
" sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n",
|
|
" if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n",
|
|
" down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n",
|
|
" opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n",
|
|
" meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n",
|
|
" \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n",
|
|
" \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n",
|
|
" return np.clip(opt, 0, 255), sar, meta\n",
|
|
"\n",
|
|
"\n",
|
|
"SCEN = { # 은폐·촬영 조건 시나리오 10종\n",
|
|
" \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n",
|
|
" \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n",
|
|
" \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n",
|
|
" for i in range(n):\n",
|
|
" cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n",
|
|
" O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n",
|
|
" m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n",
|
|
" M.append(m)\n",
|
|
" return O, S, M\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n",
|
|
"def _fit(a, fill):\n",
|
|
" \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n",
|
|
" out = np.full((N, N), fill, float)\n",
|
|
" h, w = a.shape\n",
|
|
" ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n",
|
|
" yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n",
|
|
" hh, ww = min(N, h), min(N, w)\n",
|
|
" out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"def cloud_mask(opt, sm, min_area=15):\n",
|
|
" \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n",
|
|
" bright = opt > 185\n",
|
|
" if bright.sum() < min_area:\n",
|
|
" return np.zeros_like(bright)\n",
|
|
" local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n",
|
|
" cand = bright & (local_sd < 12) & (sm < 0.3)\n",
|
|
" lab, n = ndimage.label(cand)\n",
|
|
" if n == 0:\n",
|
|
" return cand\n",
|
|
" sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n",
|
|
" return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n",
|
|
"\n",
|
|
"\n",
|
|
"def orientation(opt, sm):\n",
|
|
" \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n",
|
|
" m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n == 0:\n",
|
|
" return 0.0, m\n",
|
|
" c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n",
|
|
" m = lab == k\n",
|
|
" yy, xx = np.nonzero(m)\n",
|
|
" if len(yy) < 5:\n",
|
|
" return 0.0, m\n",
|
|
" dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n",
|
|
" return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n",
|
|
"\n",
|
|
"\n",
|
|
"_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
"\n",
|
|
"\n",
|
|
"def warp(stack, zy, zx, ang):\n",
|
|
" \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n",
|
|
" a = np.radians(ang)[:, None, None]\n",
|
|
" zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n",
|
|
" sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n",
|
|
" sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n",
|
|
" y0, x0 = sy.astype(int), sx.astype(int)\n",
|
|
" fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n",
|
|
" flat = stack.reshape(len(stack), stack.shape[1], -1)\n",
|
|
" lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n",
|
|
" g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n",
|
|
" out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n",
|
|
" return out.reshape(stack.shape)\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess_batch(O, S, M):\n",
|
|
" \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n",
|
|
" 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n",
|
|
" src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n",
|
|
" if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n",
|
|
" mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n",
|
|
" src = np.where(np.isnan(src), mean, src)\n",
|
|
" zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n",
|
|
" zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n",
|
|
" out = warp(src, zy, zx, np.zeros(len(M)))\n",
|
|
" sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n",
|
|
" angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n",
|
|
" for i in range(len(M)):\n",
|
|
" cm = cloud_mask(out[i, 0], sm[i])\n",
|
|
" if cm.any():\n",
|
|
" fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n",
|
|
" out[i, 0][cm] = fill\n",
|
|
" back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n",
|
|
" src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n",
|
|
" cloud[i] = cm.mean()\n",
|
|
" angs[i] = orientation(out[i, 0], sm[i])[0]\n",
|
|
" rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n",
|
|
" if rot.any():\n",
|
|
" out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n",
|
|
" return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(opt, sar, meta):\n",
|
|
" \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n",
|
|
" return preprocess_batch([opt], [sar], [meta])[0]\n",
|
|
"\n",
|
|
"# ---- 특징 추출 공통 함수 ----\n",
|
|
"KERNELS = [np.array(k, float) for k in (\n",
|
|
" [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n",
|
|
" [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n",
|
|
"\n",
|
|
"\n",
|
|
"def conv_features(o):\n",
|
|
" \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n",
|
|
" feats = []\n",
|
|
" for k in KERNELS:\n",
|
|
" r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n",
|
|
" feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" return np.concatenate(feats)\n",
|
|
"\n",
|
|
"\n",
|
|
"def hog(o, cell=8, bins=8):\n",
|
|
" gy, gx = np.gradient(o)\n",
|
|
" mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n",
|
|
" b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n",
|
|
" out = np.zeros((N // cell, N // cell, bins))\n",
|
|
" for i in range(N // cell):\n",
|
|
" for j in range(N // cell):\n",
|
|
" sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n",
|
|
" out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n",
|
|
" return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n",
|
|
"\n",
|
|
"\n",
|
|
"def shape_features(o, s):\n",
|
|
" \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n",
|
|
" 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" m = (sm > 0.22) | (dark > 0.12)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n:\n",
|
|
" c = lab[10:22, 8:24]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n",
|
|
" m = lab == k\n",
|
|
" p = m.sum(axis=0).astype(float)\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" if len(cols) == 0:\n",
|
|
" return np.zeros(8), o, s\n",
|
|
" thin = (p > 0) & (p <= 2)\n",
|
|
" left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n",
|
|
" if left > right:\n",
|
|
" o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" body = np.nonzero(p > 3)[0]\n",
|
|
" d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n",
|
|
" heavy = d.sum(axis=0)\n",
|
|
" centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n",
|
|
" turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n",
|
|
" return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n",
|
|
" m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n",
|
|
"\n",
|
|
"\n",
|
|
"def adv_features(o, s):\n",
|
|
" \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n",
|
|
" shp, o, s = shape_features(o, s)\n",
|
|
" c = s[8:24, 4:28]\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" m = sm > 0.2\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n",
|
|
" sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n",
|
|
"\n",
|
|
"Otr, Str, Mtr = make_dataset(600, seed=3, scen=tuple(SCEN), classes=CLASSES + [\"none\"])\n",
|
|
"clf = RandomForestClassifier(n_estimators=100, min_samples_leaf=2, random_state=0, n_jobs=1)\n",
|
|
"clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n",
|
|
"\n",
|
|
"\n",
|
|
"def process(batch, budget_ms, prof):\n",
|
|
" \"\"\"배치 처리: 타일 검사 -> 전처리 -> 특징 -> 일괄 분류. 예산을 넘기면 정밀 분류를 미루고 '객체 유무'만 먼저 표시(기능 축소).\"\"\"\n",
|
|
" ok, quarantine, X = [], [], []\n",
|
|
" t_start = time.perf_counter()\n",
|
|
" for o, s, m in batch:\n",
|
|
" try:\n",
|
|
" if o.shape != (N, N) or s.shape != (N, N) or not np.isfinite(o).mean() > 0.5 or o.max() <= 0:\n",
|
|
" raise ValueError(\"손상 타일\")\n",
|
|
" t0 = time.perf_counter(); po, ps, _ = preprocess(o, s, m); prof[\"전처리\"] += time.perf_counter() - t0\n",
|
|
" if (time.perf_counter() - t_start) * 1000 > budget_ms:\n",
|
|
" ok.append((\"객체유무\", float(ndimage.uniform_filter(ps, 3).max() > 0.25))); continue\n",
|
|
" t0 = time.perf_counter(); X.append(adv_features(po, ps)); prof[\"특징\"] += time.perf_counter() - t0\n",
|
|
" ok.append((\"정밀\", None))\n",
|
|
" except Exception as e:\n",
|
|
" quarantine.append((m[\"id\"], str(e))) # 격리 보관(유실 아님)\n",
|
|
" if X:\n",
|
|
" t0 = time.perf_counter(); clf.predict(np.array(X)); prof[\"분류\"] += time.perf_counter() - t0\n",
|
|
" return ok, quarantine\n",
|
|
"\n",
|
|
"\n",
|
|
"def workload(n, scen=(\"clean\",), broken=0.0, seed=0):\n",
|
|
" O, S, M = make_dataset(n, seed=seed, scen=scen, classes=CLASSES + [\"none\"])\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" out = []\n",
|
|
" for o, s, m in zip(O, S, M):\n",
|
|
" r = rng.random()\n",
|
|
" if r < broken / 3:\n",
|
|
" o = o[:20] # 잘린 타일\n",
|
|
" elif r < 2 * broken / 3:\n",
|
|
" o = np.full_like(o, np.nan) # 전송 오류(전부 결측)\n",
|
|
" elif r < broken:\n",
|
|
" o = np.zeros_like(o) # 빈 타일\n",
|
|
" out.append((o, s, m))\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"bad_wx = (\"cloud\", \"low_res\", \"speckle\", \"camo_occl\")\n",
|
|
"cases = [(\"평시 100장(기준)\", 100, (\"clean\",), 0, 1e9), (\"500% 증가(600장)\", 600, (\"clean\",), 0, 1e9),\n",
|
|
" (\"600장 악기상·저해상도\", 600, bad_wx, 0, 1e9), (\"600장 + 손상 타일 5%\", 600, (\"clean\",), 0.05, 1e9),\n",
|
|
" (\"600장 + 처리 예산 부족\", 600, (\"clean\",), 0, 150)]\n",
|
|
"rows, prof = [], {\"전처리\": 0.0, \"특징\": 0.0, \"분류\": 0.0}\n",
|
|
"for i, (name, n, sc, br, budget) in enumerate(cases):\n",
|
|
" q = deque(workload(n, sc, br, seed=10 + i))\n",
|
|
" received, done, quar, degraded, down = len(q), 0, 0, 0, 0\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" try:\n",
|
|
" while q:\n",
|
|
" chunk = [q.popleft() for _ in range(min(100, len(q)))]\n",
|
|
" ok, qu = process(chunk, budget, prof)\n",
|
|
" done += len(ok); quar += len(qu); degraded += sum(k == \"객체유무\" for k, _ in ok)\n",
|
|
" except Exception:\n",
|
|
" down = 1\n",
|
|
" dt = time.perf_counter() - t0\n",
|
|
" rows.append({\"상황\": name, \"수신\": received, \"처리\": done, \"격리\": quar, \"유실\": received - done - quar, \"다운\": down,\n",
|
|
" \"처리량(장/초)\": int(received / dt), \"성능저하 모드\": f\"정밀분류 지연 {degraded}장\" if degraded else \"-\"})\n",
|
|
"df = pd.DataFrame(rows)\n",
|
|
"print(df.to_string(index=False))\n",
|
|
"print(f\"\\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {df['유실'].sum() / df['수신'].sum():.1%} (목표 0%), 손상 타일은 원본 보관 후 재요청\")\n",
|
|
"\n",
|
|
"# 메모리 안정성: 같은 배치(100장)를 5회 반복 처리하며 추적 메모리 변화 확인\n",
|
|
"batch = workload(100, seed=99)\n",
|
|
"tracemalloc.start(); mem = []\n",
|
|
"for _ in range(5):\n",
|
|
" process(batch, 1e9, {\"전처리\": 0, \"특징\": 0, \"분류\": 0}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n",
|
|
"tracemalloc.stop()\n",
|
|
"print(\"메모리(동일 배치 5회, MB):\", [round(m, 2) for m in mem], f\"-> 증가 {mem[-1] - mem[0]:.2f} MB (누수 판단 기준 5MB)\")\n",
|
|
"tot = sum(prof.values())\n",
|
|
"print(\"병목 구간(누적 처리시간 비율):\", {k: f\"{v / tot:.0%}\" for k, v in sorted(prof.items(), key=lambda x: -x[1])})\n",
|
|
"thr = df.loc[1, \"처리량(장/초)\"]\n",
|
|
"area_km2 = (N * STD_GSD / 1000) ** 2\n",
|
|
"print(f\"처리 한계치 환산: 단일 코어 {thr}장/초 = 시간당 약 {thr * 3600 * area_km2:,.0f} km2 (타일 1장 {area_km2 * 1e6:.0f} m2, 표준 해상도 0.5m 기준)\")\n",
|
|
"\n",
|
|
"plt.figure(figsize=(8, 4))\n",
|
|
"plt.bar(range(len(df)), df[\"처리량(장/초)\"], 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(\"tiles / s\")\n",
|
|
"plt.title(\"Tile batch stress test (red = degraded mode)\"); plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
" 상황 수신 처리 격리 유실 다운 처리량(장/초) 성능저하 모드\n",
|
|
" 평시 100장(기준) 100 100 0 0 0 525 -\n",
|
|
" 500% 증가(600장) 600 600 0 0 0 494 -\n",
|
|
" 600장 악기상·저해상도 600 600 0 0 0 464 -\n",
|
|
"600장 + 손상 타일 5% 600 569 31 0 0 546 -\n",
|
|
"600장 + 처리 예산 부족 600 600 0 0 0 561 정밀분류 지연 111장\n",
|
|
"\n",
|
|
"시스템 다운 0건, 데이터 유실률 0.0% (목표 0%), 손상 타일은 원본 보관 후 재요청\n",
|
|
"메모리(동일 배치 5회, MB): [0.02, 0.03, 0.04, 0.05, 0.06] -> 증가 0.04 MB (누수 판단 기준 5MB)\n",
|
|
"병목 구간(누적 처리시간 비율): {'전처리': '52%', '특징': '43%', '분류': '5%'}\n",
|
|
"처리 한계치 환산: 단일 코어 494장/초 = 시간당 약 455 km2 (타일 1장 256 m2, 표준 해상도 0.5m 기준)\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 09. AAR 기반 모의 위장 표적 오판독 사례 분석 및 객체분류 모델 파라미터 재보정\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"AAR 사후검토를 통해 모의 위장 장비 오판독 사례를 분석하고 비전 모델의 특징 추출 가중치와 임계값을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 모의 위장 표적 오판독 사례를 분석하고 분류 모델의 특징 가중치와 판정 임계값을 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **모의 위장 표적이 섞인 판독 훈련 결과를 AAR 일지로 정리하고 오판독을 원인(모의 표적 오판독, 기종 혼동과 조건)별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `docs/isr01_aar_report.md 및 configs/isr01_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(사후검토) 일지 형식으로 모의 위장 표적(decoy)이 섞인 판독 훈련 결과의 오판독 사례를 원인별로 분류하고, 특징 가중치(모의 표적 학습 추가)와 SAR-광학 일치성 임계값을 재보정해 종합 정확도 향상과 재발 여부를 회귀 시험으로 확인한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n",
|
|
"# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"N = 32\n",
|
|
"STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n",
|
|
"CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n",
|
|
"MILITARY = {\"tank\", \"spg\", \"truck\"}\n",
|
|
"# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n",
|
|
"SHAPES = {\n",
|
|
" \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n",
|
|
" \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n",
|
|
" \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n",
|
|
" \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n",
|
|
" \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n",
|
|
" \"none\": [], # 객체 없는 배경 타일\n",
|
|
"}\n",
|
|
"SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n",
|
|
"\n",
|
|
"\n",
|
|
"def _mask(cls, angle, scale, squash=1.0):\n",
|
|
" \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n",
|
|
" yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
" yy = yy / squash\n",
|
|
" a = np.radians(angle)\n",
|
|
" u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n",
|
|
" v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n",
|
|
" return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_chip(cls, rng, cond=None):\n",
|
|
" \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n",
|
|
" c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n",
|
|
" gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n",
|
|
" scale = STD_GSD / gsd\n",
|
|
" angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n",
|
|
" squash = np.cos(np.radians(off))\n",
|
|
" sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n",
|
|
" sun_az = rng.uniform(0, 360)\n",
|
|
" bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n",
|
|
" parts = _mask(cls, angle, scale, squash)\n",
|
|
" body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n",
|
|
" opt = bg.copy()\n",
|
|
" # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n",
|
|
" L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n",
|
|
" sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n",
|
|
" opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n",
|
|
" if cls == \"civil\":\n",
|
|
" base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n",
|
|
" opt[body] = base + rng.normal(0, 8, body.sum())\n",
|
|
" roof = parts[1][0]\n",
|
|
" opt[roof] = base * 0.8\n",
|
|
" else:\n",
|
|
" ref = bg[body].mean() if body.any() else bg.mean()\n",
|
|
" for m, d in parts:\n",
|
|
" opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n",
|
|
" # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n",
|
|
" if c[\"camo\"] > 0 and cls != \"civil\":\n",
|
|
" net = ndimage.binary_dilation(body, iterations=2)\n",
|
|
" tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n",
|
|
" opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n",
|
|
" # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n",
|
|
" if c[\"occl\"] > 0:\n",
|
|
" trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n",
|
|
" near = ndimage.binary_dilation(body, iterations=3)\n",
|
|
" cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n",
|
|
" opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n",
|
|
" # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n",
|
|
" cmask = np.zeros((N, N), bool)\n",
|
|
" if c[\"cloud\"] > 0:\n",
|
|
" blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n",
|
|
" cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n",
|
|
" opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n",
|
|
" # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n",
|
|
" sar = np.full((N, N), 0.12)\n",
|
|
" sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n",
|
|
" edge = body & ~ndimage.binary_erosion(body)\n",
|
|
" sar[edge] *= 1.3 if cls in MILITARY else 1.0\n",
|
|
" sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n",
|
|
" if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n",
|
|
" down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n",
|
|
" opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n",
|
|
" meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n",
|
|
" \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n",
|
|
" \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n",
|
|
" return np.clip(opt, 0, 255), sar, meta\n",
|
|
"\n",
|
|
"\n",
|
|
"SCEN = { # 은폐·촬영 조건 시나리오 10종\n",
|
|
" \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n",
|
|
" \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n",
|
|
" \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n",
|
|
" for i in range(n):\n",
|
|
" cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n",
|
|
" O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n",
|
|
" m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n",
|
|
" M.append(m)\n",
|
|
" return O, S, M\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n",
|
|
"def _fit(a, fill):\n",
|
|
" \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n",
|
|
" out = np.full((N, N), fill, float)\n",
|
|
" h, w = a.shape\n",
|
|
" ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n",
|
|
" yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n",
|
|
" hh, ww = min(N, h), min(N, w)\n",
|
|
" out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"def cloud_mask(opt, sm, min_area=15):\n",
|
|
" \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n",
|
|
" bright = opt > 185\n",
|
|
" if bright.sum() < min_area:\n",
|
|
" return np.zeros_like(bright)\n",
|
|
" local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n",
|
|
" cand = bright & (local_sd < 12) & (sm < 0.3)\n",
|
|
" lab, n = ndimage.label(cand)\n",
|
|
" if n == 0:\n",
|
|
" return cand\n",
|
|
" sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n",
|
|
" return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n",
|
|
"\n",
|
|
"\n",
|
|
"def orientation(opt, sm):\n",
|
|
" \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n",
|
|
" m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n == 0:\n",
|
|
" return 0.0, m\n",
|
|
" c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n",
|
|
" m = lab == k\n",
|
|
" yy, xx = np.nonzero(m)\n",
|
|
" if len(yy) < 5:\n",
|
|
" return 0.0, m\n",
|
|
" dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n",
|
|
" return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n",
|
|
"\n",
|
|
"\n",
|
|
"_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
"\n",
|
|
"\n",
|
|
"def warp(stack, zy, zx, ang):\n",
|
|
" \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n",
|
|
" a = np.radians(ang)[:, None, None]\n",
|
|
" zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n",
|
|
" sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n",
|
|
" sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n",
|
|
" y0, x0 = sy.astype(int), sx.astype(int)\n",
|
|
" fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n",
|
|
" flat = stack.reshape(len(stack), stack.shape[1], -1)\n",
|
|
" lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n",
|
|
" g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n",
|
|
" out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n",
|
|
" return out.reshape(stack.shape)\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess_batch(O, S, M):\n",
|
|
" \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n",
|
|
" 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n",
|
|
" src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n",
|
|
" if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n",
|
|
" mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n",
|
|
" src = np.where(np.isnan(src), mean, src)\n",
|
|
" zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n",
|
|
" zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n",
|
|
" out = warp(src, zy, zx, np.zeros(len(M)))\n",
|
|
" sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n",
|
|
" angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n",
|
|
" for i in range(len(M)):\n",
|
|
" cm = cloud_mask(out[i, 0], sm[i])\n",
|
|
" if cm.any():\n",
|
|
" fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n",
|
|
" out[i, 0][cm] = fill\n",
|
|
" back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n",
|
|
" src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n",
|
|
" cloud[i] = cm.mean()\n",
|
|
" angs[i] = orientation(out[i, 0], sm[i])[0]\n",
|
|
" rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n",
|
|
" if rot.any():\n",
|
|
" out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n",
|
|
" return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(opt, sar, meta):\n",
|
|
" \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n",
|
|
" return preprocess_batch([opt], [sar], [meta])[0]\n",
|
|
"\n",
|
|
"# ---- 특징 추출 공통 함수 ----\n",
|
|
"KERNELS = [np.array(k, float) for k in (\n",
|
|
" [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n",
|
|
" [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n",
|
|
"\n",
|
|
"\n",
|
|
"def conv_features(o):\n",
|
|
" \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n",
|
|
" feats = []\n",
|
|
" for k in KERNELS:\n",
|
|
" r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n",
|
|
" feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" return np.concatenate(feats)\n",
|
|
"\n",
|
|
"\n",
|
|
"def hog(o, cell=8, bins=8):\n",
|
|
" gy, gx = np.gradient(o)\n",
|
|
" mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n",
|
|
" b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n",
|
|
" out = np.zeros((N // cell, N // cell, bins))\n",
|
|
" for i in range(N // cell):\n",
|
|
" for j in range(N // cell):\n",
|
|
" sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n",
|
|
" out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n",
|
|
" return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n",
|
|
"\n",
|
|
"\n",
|
|
"def shape_features(o, s):\n",
|
|
" \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n",
|
|
" 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" m = (sm > 0.22) | (dark > 0.12)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n:\n",
|
|
" c = lab[10:22, 8:24]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n",
|
|
" m = lab == k\n",
|
|
" p = m.sum(axis=0).astype(float)\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" if len(cols) == 0:\n",
|
|
" return np.zeros(8), o, s\n",
|
|
" thin = (p > 0) & (p <= 2)\n",
|
|
" left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n",
|
|
" if left > right:\n",
|
|
" o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" body = np.nonzero(p > 3)[0]\n",
|
|
" d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n",
|
|
" heavy = d.sum(axis=0)\n",
|
|
" centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n",
|
|
" turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n",
|
|
" return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n",
|
|
" m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n",
|
|
"\n",
|
|
"\n",
|
|
"def adv_features(o, s):\n",
|
|
" \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n",
|
|
" shp, o, s = shape_features(o, s)\n",
|
|
" c = s[8:24, 4:28]\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" m = sm > 0.2\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n",
|
|
" sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n",
|
|
"\n",
|
|
"EX_SCEN = (\"clean\", \"camo\", \"occl30\", \"low_sun\", \"cloud\")\n",
|
|
"\n",
|
|
"\n",
|
|
"def featurize(O, S, M):\n",
|
|
" P = preprocess_batch(O, S, M)\n",
|
|
" return np.array([adv_features(o, s) for o, s, _ in P]), np.array([p[1][8:24, 4:28].mean() for p in P])\n",
|
|
"\n",
|
|
"\n",
|
|
"def train(with_decoy, seed=3):\n",
|
|
" cls = CLASSES + ([\"decoy\"] if with_decoy else [])\n",
|
|
" O, S, M = make_dataset(250 * len(cls), seed=seed, scen=tuple(SCEN), classes=cls)\n",
|
|
" X, _ = featurize(O, S, M)\n",
|
|
" return RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1).fit(X, [m[\"cls\"] for m in M])\n",
|
|
"\n",
|
|
"\n",
|
|
"def decide(model, X, sar, tau):\n",
|
|
" \"\"\"분류 결과 + SAR-광학 일치성 규칙(선택): 전차·자주포로 분류했는데 SAR 금속 반사가 tau 미만이면 '모의 표적 의심'.\"\"\"\n",
|
|
" p = model.predict(X).astype(object)\n",
|
|
" if tau is not None:\n",
|
|
" p[np.isin(p, [\"tank\", \"spg\"]) & (sar < tau)] = \"decoy\"\n",
|
|
" return p\n",
|
|
"\n",
|
|
"\n",
|
|
"def aar_log(y, p, sc, ids):\n",
|
|
" rows = []\n",
|
|
" for yi, pi, s, i in zip(y, p, sc, ids):\n",
|
|
" if yi == pi:\n",
|
|
" continue\n",
|
|
" if yi == \"decoy\":\n",
|
|
" cause = f\"모의 표적을 {pi}(으)로 오판독\"\n",
|
|
" elif pi == \"decoy\":\n",
|
|
" cause = f\"실제 {yi}를 모의 표적으로 오판\"\n",
|
|
" else:\n",
|
|
" cause = f\"{yi}->{pi} 기종 혼동({s})\"\n",
|
|
" rows.append({\"원인\": cause, \"영상\": i})\n",
|
|
" return pd.DataFrame(rows, columns=[\"원인\", \"영상\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"# 모의 판독 훈련 자료(모의 위장 표적 20% 포함): AAR 대상 500장 + 회귀 시험 500장\n",
|
|
"Oa, Sa, Ma = make_dataset(500, seed=21, scen=EX_SCEN, classes=CLASSES + [\"decoy\"])\n",
|
|
"Ob, Sb, Mb = make_dataset(500, seed=22, scen=EX_SCEN, classes=CLASSES + [\"decoy\"])\n",
|
|
"Xa, sar_a = featurize(Oa, Sa, Ma); ya, sca, ida = np.array([m[\"cls\"] for m in Ma]), [m[\"scen\"] for m in Ma], [m[\"id\"] for m in Ma]\n",
|
|
"Xb, sar_b = featurize(Ob, Sb, Mb); yb = np.array([m[\"cls\"] for m in Mb])\n",
|
|
"\n",
|
|
"# 1) 초기 설정: 모의 표적을 학습하지 않은 모델, 일치성 규칙 없음\n",
|
|
"m0 = train(False)\n",
|
|
"p0 = decide(m0, Xa, sar_a, None)\n",
|
|
"acc0 = (p0 == ya).mean()\n",
|
|
"log0 = aar_log(ya, p0, sca, ida)\n",
|
|
"print(f\"[1. 무엇이 일어났나] 초기 종합 정확도 {acc0:.3f}, 오판독 {len(log0)}건\")\n",
|
|
"print(log0[\"원인\"].value_counts().head(6).to_string())\n",
|
|
"print(f\"\\n[2. 왜 일어났나] 모의 표적 SAR 반사 평균 {sar_a[ya == 'decoy'].mean():.3f} vs 실제 전차·자주포 {sar_a[np.isin(ya, ['tank', 'spg'])].mean():.3f}\"\n",
|
|
" \" -> 광학 윤곽은 전차와 같지만 금속 반사가 약해, 모의 표적을 모르는 모델은 반사가 약한 민간 차량으로 몰아 버린다.\")\n",
|
|
"\n",
|
|
"# 2) 재보정: (모의 표적 학습 여부) x (SAR 일치성 임계값) 격자 탐색\n",
|
|
"m1 = train(True)\n",
|
|
"grid = []\n",
|
|
"for name, mdl in [(\"모의표적 미학습\", m0), (\"모의표적 학습\", m1)]:\n",
|
|
" for tau in [None, 0.12, 0.15, 0.18, 0.22]:\n",
|
|
" p = decide(mdl, Xa, sar_a, tau)\n",
|
|
" mil = np.isin(ya, list(MILITARY))\n",
|
|
" grid.append({\"모델\": name, \"SAR 임계값\": \"-\" if tau is None else tau, \"종합정확도\": round(float((p == ya).mean()), 3),\n",
|
|
" \"모의표적 적발률\": round(float((p[ya == \"decoy\"] == \"decoy\").mean()), 3),\n",
|
|
" \"실제 장비를 모의로 오판\": int((mil & (p == \"decoy\")).sum())})\n",
|
|
"g = pd.DataFrame(grid).sort_values([\"종합정확도\", \"실제 장비를 모의로 오판\"], ascending=[False, True])\n",
|
|
"print(\"\\n[3. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n",
|
|
"print(g.head().to_string(index=False))\n",
|
|
"best = g.iloc[0]\n",
|
|
"mdl = m1 if best[\"모델\"] == \"모의표적 학습\" else m0\n",
|
|
"tau = None if best[\"SAR 임계값\"] == \"-\" else float(best[\"SAR 임계값\"])\n",
|
|
"p1 = decide(mdl, Xa, sar_a, tau)\n",
|
|
"acc1 = (p1 == ya).mean()\n",
|
|
"print(f\"\\n재보정값: {best['모델']}, SAR 일치성 임계값 {best['SAR 임계값']}\")\n",
|
|
"print(f\"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.1 else '미달'})\")\n",
|
|
"b0, b1 = (decide(m0, Xb, sar_b, None) == yb).mean(), (decide(mdl, Xb, sar_b, tau) == yb).mean()\n",
|
|
"print(f\"회귀 시험(별도 500장): {b0:.3f} -> {b1:.3f}\")\n",
|
|
"log1 = aar_log(ya, p1, sca, ida)\n",
|
|
"rep = set(log0[\"영상\"]) & set(log1[\"영상\"])\n",
|
|
"print(f\"재보정 후 남은 오판독 {len(log1)}건, 그중 재발 {len(rep)}건:\", log1[log1[\"영상\"].isin(rep)][\"원인\"].value_counts().head(3).to_dict())\n",
|
|
"print(\"SAR 일치성 규칙은 모의 표적 학습 후 추가 이득이 없고 임계값을 높이면 실제 장비를 모의 표적으로 오판하므로 적용하지 않는다(-).\")\n",
|
|
"print(\"[4. 남은 과제] 재발 건은 위장막 아래 기종 혼동으로, 다른 촬영 회차 교차 판독(미션 7)과 지속 학습 대상 등록으로 대응한다.\")\n",
|
|
"\n",
|
|
"tuned = f\"model:\\n train_with_decoy: {best['모델'] == '모의표적 학습'}\\n rf_trees: 150\\nrules:\\n sar_consistency_tau: {best['SAR 임계값']}\\nconfidence:\\n high: 0.9\\n medium: 0.6\"\n",
|
|
"print(\"\\n[재보정 파라미터(isr01_tuned_params.yaml 내용)]\\n\" + tuned)\n",
|
|
"\n",
|
|
"plt.figure(figsize=(7, 4))\n",
|
|
"plt.hist([sar_a[np.isin(ya, [\"tank\", \"spg\"])], sar_a[ya == \"decoy\"]], bins=25, label=[\"tank/spg\", \"decoy\"], alpha=.8)\n",
|
|
"if tau:\n",
|
|
" plt.axvline(tau, ls=\"--\", c=\"k\", label=f\"tau={tau}\")\n",
|
|
"plt.xlabel(\"mean SAR return (center)\"); plt.ylabel(\"chips\"); plt.legend(); plt.title(\"SAR-optical consistency for decoy screening\")\n",
|
|
"plt.show()\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[1. 무엇이 일어났나] 초기 종합 정확도 0.786, 오판독 107건\n",
|
|
"원인\n",
|
|
"모의 표적을 civil(으)로 오판독 100\n",
|
|
"tank->truck 기종 혼동(camo) 4\n",
|
|
"truck->tank 기종 혼동(cloud) 1\n",
|
|
"tank->truck 기종 혼동(clean) 1\n",
|
|
"tank->truck 기종 혼동(cloud) 1\n",
|
|
"\n",
|
|
"[2. 왜 일어났나] 모의 표적 SAR 반사 평균 0.089 vs 실제 전차·자주포 0.226 -> 광학 윤곽은 전차와 같지만 금속 반사가 약해, 모의 표적을 모르는 모델은 반사가 약한 민간 차량으로 몰아 버린다.\n",
|
|
"\n",
|
|
"[3. 무엇을 바꾸나] 격자 탐색 상위 5개\n",
|
|
" 모델 SAR 임계값 종합정확도 모의표적 적발률 실제 장비를 모의로 오판\n",
|
|
"모의표적 학습 - 0.990 1.0 0\n",
|
|
"모의표적 학습 0.12 0.990 1.0 0\n",
|
|
"모의표적 학습 0.15 0.990 1.0 0\n",
|
|
"모의표적 학습 0.18 0.960 1.0 16\n",
|
|
"모의표적 학습 0.22 0.788 1.0 102\n",
|
|
"\n",
|
|
"재보정값: 모의표적 학습, SAR 일치성 임계값 -\n",
|
|
"종합 정확도 0.786 -> 0.990 (+20.4%p, 목표 +10%p: 달성)\n",
|
|
"회귀 시험(별도 500장): 0.790 -> 0.984\n",
|
|
"재보정 후 남은 오판독 5건, 그중 재발 5건: {'tank->truck 기종 혼동(camo)': 4, 'truck->tank 기종 혼동(cloud)': 1}\n",
|
|
"SAR 일치성 규칙은 모의 표적 학습 후 추가 이득이 없고 임계값을 높이면 실제 장비를 모의 표적으로 오판하므로 적용하지 않는다(-).\n",
|
|
"[4. 남은 과제] 재발 건은 위장막 아래 기종 혼동으로, 다른 촬영 회차 교차 판독(미션 7)과 지속 학습 대상 등록으로 대응한다.\n",
|
|
"\n",
|
|
"[재보정 파라미터(isr01_tuned_params.yaml 내용)]\n",
|
|
"model:\n",
|
|
" train_with_decoy: True\n",
|
|
" rf_trees: 150\n",
|
|
"rules:\n",
|
|
" sar_consistency_tau: -\n",
|
|
"confidence:\n",
|
|
" high: 0.9\n",
|
|
" medium: 0.6\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 10. 공개 위성영상 장비 객체분류 시스템 통합 및 전략 ISR 평가 브리핑 패키지 완성\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"공개 위성영상 장비 객체분류 시스템을 최종 통합 검증하고 전략 정보 판단용 브리핑 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **메타데이터 검증부터 전처리, 정밀 분류, 신뢰도 엔진, 판독 대기열, 분석 화면까지 시스템을 통합 검증하고 전략 ISR 평가 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **재보정 설정으로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 타일과 메타데이터 오류가 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/main_isr01.py, README.md, docs/isr01_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": [
|
|
"\"\"\"메타데이터 검증·전처리·정밀 분류(모의 표적 포함)·신뢰도 엔진·영상분석관 판독 대기열·분석 화면까지 공개 위성영상 장비 객체분류 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 전략 ISR 평가 브리핑 요약을 출력한다.\"\"\"\n",
|
|
"import hashlib\n",
|
|
"import json\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from scipy import ndimage\n",
|
|
"from sklearn.ensemble import RandomForestClassifier\n",
|
|
"from sklearn.linear_model import LogisticRegression\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n",
|
|
"# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"N = 32\n",
|
|
"STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n",
|
|
"CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n",
|
|
"MILITARY = {\"tank\", \"spg\", \"truck\"}\n",
|
|
"# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n",
|
|
"SHAPES = {\n",
|
|
" \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n",
|
|
" \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n",
|
|
" \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n",
|
|
" \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n",
|
|
" \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n",
|
|
" \"none\": [], # 객체 없는 배경 타일\n",
|
|
"}\n",
|
|
"SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n",
|
|
"\n",
|
|
"\n",
|
|
"def _mask(cls, angle, scale, squash=1.0):\n",
|
|
" \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n",
|
|
" yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
" yy = yy / squash\n",
|
|
" a = np.radians(angle)\n",
|
|
" u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n",
|
|
" v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n",
|
|
" return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_chip(cls, rng, cond=None):\n",
|
|
" \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n",
|
|
" c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n",
|
|
" gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n",
|
|
" scale = STD_GSD / gsd\n",
|
|
" angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n",
|
|
" squash = np.cos(np.radians(off))\n",
|
|
" sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n",
|
|
" sun_az = rng.uniform(0, 360)\n",
|
|
" bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n",
|
|
" parts = _mask(cls, angle, scale, squash)\n",
|
|
" body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n",
|
|
" opt = bg.copy()\n",
|
|
" # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n",
|
|
" L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n",
|
|
" sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n",
|
|
" opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n",
|
|
" if cls == \"civil\":\n",
|
|
" base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n",
|
|
" opt[body] = base + rng.normal(0, 8, body.sum())\n",
|
|
" roof = parts[1][0]\n",
|
|
" opt[roof] = base * 0.8\n",
|
|
" else:\n",
|
|
" ref = bg[body].mean() if body.any() else bg.mean()\n",
|
|
" for m, d in parts:\n",
|
|
" opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n",
|
|
" # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n",
|
|
" if c[\"camo\"] > 0 and cls != \"civil\":\n",
|
|
" net = ndimage.binary_dilation(body, iterations=2)\n",
|
|
" tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n",
|
|
" opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n",
|
|
" # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n",
|
|
" if c[\"occl\"] > 0:\n",
|
|
" trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n",
|
|
" near = ndimage.binary_dilation(body, iterations=3)\n",
|
|
" cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n",
|
|
" opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n",
|
|
" # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n",
|
|
" cmask = np.zeros((N, N), bool)\n",
|
|
" if c[\"cloud\"] > 0:\n",
|
|
" blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n",
|
|
" cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n",
|
|
" opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n",
|
|
" # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n",
|
|
" sar = np.full((N, N), 0.12)\n",
|
|
" sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n",
|
|
" edge = body & ~ndimage.binary_erosion(body)\n",
|
|
" sar[edge] *= 1.3 if cls in MILITARY else 1.0\n",
|
|
" sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n",
|
|
" if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n",
|
|
" down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n",
|
|
" opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n",
|
|
" meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n",
|
|
" \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n",
|
|
" \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n",
|
|
" return np.clip(opt, 0, 255), sar, meta\n",
|
|
"\n",
|
|
"\n",
|
|
"SCEN = { # 은폐·촬영 조건 시나리오 10종\n",
|
|
" \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n",
|
|
" \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n",
|
|
" \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n",
|
|
" for i in range(n):\n",
|
|
" cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n",
|
|
" O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n",
|
|
" m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n",
|
|
" M.append(m)\n",
|
|
" return O, S, M\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"\n",
|
|
"# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n",
|
|
"def _fit(a, fill):\n",
|
|
" \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n",
|
|
" out = np.full((N, N), fill, float)\n",
|
|
" h, w = a.shape\n",
|
|
" ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n",
|
|
" yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n",
|
|
" hh, ww = min(N, h), min(N, w)\n",
|
|
" out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n",
|
|
" return out\n",
|
|
"\n",
|
|
"\n",
|
|
"def cloud_mask(opt, sm, min_area=15):\n",
|
|
" \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n",
|
|
" bright = opt > 185\n",
|
|
" if bright.sum() < min_area:\n",
|
|
" return np.zeros_like(bright)\n",
|
|
" local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n",
|
|
" cand = bright & (local_sd < 12) & (sm < 0.3)\n",
|
|
" lab, n = ndimage.label(cand)\n",
|
|
" if n == 0:\n",
|
|
" return cand\n",
|
|
" sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n",
|
|
" return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n",
|
|
"\n",
|
|
"\n",
|
|
"def orientation(opt, sm):\n",
|
|
" \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n",
|
|
" m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n == 0:\n",
|
|
" return 0.0, m\n",
|
|
" c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n",
|
|
" m = lab == k\n",
|
|
" yy, xx = np.nonzero(m)\n",
|
|
" if len(yy) < 5:\n",
|
|
" return 0.0, m\n",
|
|
" dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n",
|
|
" return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n",
|
|
"\n",
|
|
"\n",
|
|
"_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n",
|
|
"\n",
|
|
"\n",
|
|
"def warp(stack, zy, zx, ang):\n",
|
|
" \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n",
|
|
" a = np.radians(ang)[:, None, None]\n",
|
|
" zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n",
|
|
" sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n",
|
|
" sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n",
|
|
" y0, x0 = sy.astype(int), sx.astype(int)\n",
|
|
" fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n",
|
|
" flat = stack.reshape(len(stack), stack.shape[1], -1)\n",
|
|
" lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n",
|
|
" g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n",
|
|
" out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n",
|
|
" return out.reshape(stack.shape)\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess_batch(O, S, M):\n",
|
|
" \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n",
|
|
" 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n",
|
|
" src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n",
|
|
" if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n",
|
|
" mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n",
|
|
" src = np.where(np.isnan(src), mean, src)\n",
|
|
" zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n",
|
|
" zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n",
|
|
" out = warp(src, zy, zx, np.zeros(len(M)))\n",
|
|
" sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n",
|
|
" angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n",
|
|
" for i in range(len(M)):\n",
|
|
" cm = cloud_mask(out[i, 0], sm[i])\n",
|
|
" if cm.any():\n",
|
|
" fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n",
|
|
" out[i, 0][cm] = fill\n",
|
|
" back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n",
|
|
" src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n",
|
|
" cloud[i] = cm.mean()\n",
|
|
" angs[i] = orientation(out[i, 0], sm[i])[0]\n",
|
|
" rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n",
|
|
" if rot.any():\n",
|
|
" out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n",
|
|
" return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def preprocess(opt, sar, meta):\n",
|
|
" \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n",
|
|
" return preprocess_batch([opt], [sar], [meta])[0]\n",
|
|
"\n",
|
|
"# ---- 특징 추출 공통 함수 ----\n",
|
|
"KERNELS = [np.array(k, float) for k in (\n",
|
|
" [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n",
|
|
" [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n",
|
|
"\n",
|
|
"\n",
|
|
"def conv_features(o):\n",
|
|
" \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n",
|
|
" feats = []\n",
|
|
" for k in KERNELS:\n",
|
|
" r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n",
|
|
" feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n",
|
|
" return np.concatenate(feats)\n",
|
|
"\n",
|
|
"\n",
|
|
"def hog(o, cell=8, bins=8):\n",
|
|
" gy, gx = np.gradient(o)\n",
|
|
" mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n",
|
|
" b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n",
|
|
" out = np.zeros((N // cell, N // cell, bins))\n",
|
|
" for i in range(N // cell):\n",
|
|
" for j in range(N // cell):\n",
|
|
" sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n",
|
|
" out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n",
|
|
" return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n",
|
|
"\n",
|
|
"\n",
|
|
"def shape_features(o, s):\n",
|
|
" \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n",
|
|
" 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" m = (sm > 0.22) | (dark > 0.12)\n",
|
|
" lab, n = ndimage.label(m)\n",
|
|
" if n:\n",
|
|
" c = lab[10:22, 8:24]\n",
|
|
" k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n",
|
|
" m = lab == k\n",
|
|
" p = m.sum(axis=0).astype(float)\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" if len(cols) == 0:\n",
|
|
" return np.zeros(8), o, s\n",
|
|
" thin = (p > 0) & (p <= 2)\n",
|
|
" left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n",
|
|
" if left > right:\n",
|
|
" o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n",
|
|
" cols = np.nonzero(p)[0]\n",
|
|
" body = np.nonzero(p > 3)[0]\n",
|
|
" d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n",
|
|
" heavy = d.sum(axis=0)\n",
|
|
" centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n",
|
|
" turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n",
|
|
" return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n",
|
|
" m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n",
|
|
"\n",
|
|
"\n",
|
|
"def adv_features(o, s):\n",
|
|
" \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n",
|
|
" shp, o, s = shape_features(o, s)\n",
|
|
" c = s[8:24, 4:28]\n",
|
|
" sm = ndimage.uniform_filter(s, 3)\n",
|
|
" m = sm > 0.2\n",
|
|
" dark = np.abs(o - np.median(o))\n",
|
|
" return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n",
|
|
" sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n",
|
|
" [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n",
|
|
"\n",
|
|
"CONFIG = {\"train_with_decoy\": True, \"high\": 0.9, \"medium\": 0.6, \"hvt_types\": [\"spg\"]} # 미션 9 재보정값\n",
|
|
"CALLED = set()\n",
|
|
"\n",
|
|
"\n",
|
|
"def cover(fn):\n",
|
|
" \"\"\"기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다.\"\"\"\n",
|
|
" def wrap(*a, **k):\n",
|
|
" CALLED.add(fn.__name__)\n",
|
|
" return fn(*a, **k)\n",
|
|
" wrap.__name__ = fn.__name__\n",
|
|
" return wrap\n",
|
|
"\n",
|
|
"\n",
|
|
"preprocess, adv_features, shape_features, hog, cloud_mask, orientation = map(cover, (preprocess, adv_features, shape_features, hog, cloud_mask, orientation))\n",
|
|
"\n",
|
|
"\n",
|
|
"@cover\n",
|
|
"def validate_tile(o, s, m):\n",
|
|
" errs = []\n",
|
|
" if o.shape != (N, N) or s.shape != (N, N):\n",
|
|
" errs.append(\"타일 크기 오류\")\n",
|
|
" if not 0.1 <= m[\"gsd\"] <= 30:\n",
|
|
" errs.append(\"gsd 범위 오류\")\n",
|
|
" if not np.isfinite(o).all():\n",
|
|
" errs.append(\"결측 픽셀\")\n",
|
|
" return errs\n",
|
|
"\n",
|
|
"\n",
|
|
"@cover\n",
|
|
"def quality(P, prob):\n",
|
|
" srt = np.sort(prob, axis=1)\n",
|
|
" ent = -(prob * np.log(np.clip(prob, 1e-9, 1))).sum(1) / np.log(prob.shape[1])\n",
|
|
" return np.c_[srt[:, -1], srt[:, -1] - srt[:, -2], ent, [p[2] for p in P], [p[1][8:24, 4:28].mean() for p in P]]\n",
|
|
"\n",
|
|
"\n",
|
|
"@cover\n",
|
|
"def grade(c):\n",
|
|
" return \"High\" if c >= CONFIG[\"high\"] else (\"Medium\" if c >= CONFIG[\"medium\"] else \"Low\")\n",
|
|
"\n",
|
|
"\n",
|
|
"class Queue:\n",
|
|
" def __init__(self):\n",
|
|
" self.items, self.log = [], []\n",
|
|
"\n",
|
|
" @cover\n",
|
|
" def submit(self, iid, pred, g, conf):\n",
|
|
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
|
|
" body = {\"event\": \"SUBMIT\", \"id\": iid, \"pred\": pred, \"grade\": g, \"conf\": round(conf, 3), \"hvt\": pred in CONFIG[\"hvt_types\"]}\n",
|
|
" self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n",
|
|
" self.items.append({**body, \"status\": \"PENDING\"})\n",
|
|
"\n",
|
|
" @cover\n",
|
|
" def verify(self):\n",
|
|
" prev = \"GENESIS\"\n",
|
|
" for r in self.log:\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, sort_keys=True)).encode()).hexdigest():\n",
|
|
" return False\n",
|
|
" prev = r[\"hash\"]\n",
|
|
" return True\n",
|
|
"\n",
|
|
"\n",
|
|
"TARGETS = {\"validate_tile\", \"preprocess\", \"cloud_mask\", \"orientation\", \"adv_features\", \"shape_features\", \"hog\", \"quality\", \"grade\", \"submit\", \"verify\", \"dashboard\"}\n",
|
|
"LABELS = CLASSES + [\"decoy\", \"none\"]\n",
|
|
"\n",
|
|
"# ---------------- 1. 학습(분류 모델 + 신뢰도 가중치) ----------------\n",
|
|
"t_start = time.perf_counter()\n",
|
|
"O, S, M = make_dataset(200 * len(LABELS), seed=3, scen=tuple(SCEN), classes=LABELS)\n",
|
|
"clf = RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1)\n",
|
|
"clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(O, S, M)]), [m[\"cls\"] for m in M])\n",
|
|
"Oc, Sc, Mc = make_dataset(480, seed=6, scen=tuple(SCEN), classes=LABELS)\n",
|
|
"Pc = preprocess_batch(Oc, Sc, Mc)\n",
|
|
"prc = clf.predict_proba(np.array([adv_features(o, s) for o, s, _ in Pc]))\n",
|
|
"conf_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(quality(Pc, prc), clf.classes_[prc.argmax(1)] == np.array([m[\"cls\"] for m in Mc]))\n",
|
|
"\n",
|
|
"# ---------------- 2. 운용 자료(가상 판독 지역 300타일) 처리 ----------------\n",
|
|
"Oo, So, Mo = make_dataset(300, seed=31, scen=(\"clean\", \"camo\", \"occl30\", \"cloud\", \"low_sun\", \"low_res\"), classes=LABELS)\n",
|
|
"ops = list(zip(Oo, So, Mo))\n",
|
|
"ops[5] = (Oo[5][:20], So[5], Mo[5]) # 손상 타일 1건(잘림)\n",
|
|
"ops[9] = (Oo[9], So[9], {**Mo[9], \"gsd\": 0.0}) # 메타데이터 오류 1건\n",
|
|
"queue, rejected, res, ms = Queue(), {}, [], []\n",
|
|
"for o, s, m in ops:\n",
|
|
" errs = validate_tile(o, s, m)\n",
|
|
" if errs:\n",
|
|
" rejected[m[\"id\"]] = errs; continue\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" P = [preprocess(o, s, m)]\n",
|
|
" pr = clf.predict_proba([adv_features(P[0][0], P[0][1])])\n",
|
|
" pred = clf.classes_[pr.argmax()]\n",
|
|
" conf = float(conf_model.predict_proba(quality(P, pr))[0, 1])\n",
|
|
" g = grade(conf)\n",
|
|
" if pred != \"none\":\n",
|
|
" queue.submit(m[\"id\"], pred, g, conf)\n",
|
|
" ms.append((time.perf_counter() - t0) * 1000)\n",
|
|
" res.append({\"id\": m[\"id\"], \"y\": m[\"cls\"], \"p\": pred, \"grade\": g, \"conf\": conf, \"scen\": m[\"scen\"]})\n",
|
|
"R = pd.DataFrame(res)\n",
|
|
"acc = (R.y == R.p).mean()\n",
|
|
"milR = R[R.y.isin(list(MILITARY))]\n",
|
|
"rec = (milR.y == milR.p).mean()\n",
|
|
"decoy_catch = (R[R.y == \"decoy\"].p == \"decoy\").mean()\n",
|
|
"hi = R[(R.grade == \"High\") & R.p.isin(list(MILITARY))]\n",
|
|
"hi_err = (hi.y != hi.p).mean() if len(hi) else 0.0\n",
|
|
"\n",
|
|
"\n",
|
|
"# ---------------- 3. 분석 화면 ----------------\n",
|
|
"@cover\n",
|
|
"def dashboard():\n",
|
|
" plt.style.use(\"dark_background\")\n",
|
|
" fig, ax = plt.subplots(2, 2, figsize=(12, 9))\n",
|
|
" T = 10\n",
|
|
" mosaic = np.block([[np.asarray(Oo[r * T + c]) for c in range(T)] for r in range(T)])\n",
|
|
" ax[0, 0].imshow(mosaic, cmap=\"gray\")\n",
|
|
" for k, r in R[R.id.isin([f\"IMG{i:04d}\" for i in range(100)])].iterrows():\n",
|
|
" i = int(r.id[3:])\n",
|
|
" if r.p != \"none\":\n",
|
|
" ax[0, 0].scatter(i % T * N + 16, i // T * N + 16, s=60, facecolors=\"none\",\n",
|
|
" edgecolors=\"red\" if r.p in MILITARY else (\"yellow\" if r.p == \"decoy\" else \"deepskyblue\"))\n",
|
|
" ax[0, 0].set_title(\"First 100 tiles (red = military, yellow = decoy)\"); ax[0, 0].axis(\"off\")\n",
|
|
" vc = R[R.p != \"none\"].p.value_counts()\n",
|
|
" ax[0, 1].bar(vc.index.tolist(), vc.values, color=\"deepskyblue\"); ax[0, 1].set_title(\"Detections by type\")\n",
|
|
" gc = R[R.p != \"none\"].grade.value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n",
|
|
" ax[1, 0].bar(gc.index.tolist(), gc.values, color=[\"red\", \"orange\", \"gray\"]); ax[1, 0].set_title(\"Review queue by grade (all pending)\")\n",
|
|
" ax[1, 1].axis(\"off\")\n",
|
|
" ax[1, 1].text(0, .9, f\"Accuracy {acc:.1%}\\nMilitary rec. {rec:.1%}\\nDecoy caught {decoy_catch:.1%}\\nQueue {len(queue.items)} pending\\n\"\n",
|
|
" f\"Latency {np.mean(ms):.1f} ms/tile\", family=\"monospace\", fontsize=13, va=\"top\")\n",
|
|
" fig.suptitle(\"ISR01 integrated satellite object classification - synthetic data only\"); plt.tight_layout(); plt.show()\n",
|
|
"\n",
|
|
"\n",
|
|
"dashboard()\n",
|
|
"\n",
|
|
"# ---------------- 4. 자체 점검(통합 테스트) ----------------\n",
|
|
"log_ok = queue.verify()\n",
|
|
"cov = len(CALLED & TARGETS) / len(TARGETS)\n",
|
|
"chk = {\n",
|
|
" \"손상 타일·메타데이터 오류 차단\": set(rejected) == {\"IMG0005\", \"IMG0009\"},\n",
|
|
" \"처리 타일 298장\": len(res) == 298,\n",
|
|
" \"종합 정확도 90% 이상\": acc >= 0.9,\n",
|
|
" \"군용 기종 식별 재현율 85% 이상\": rec >= 0.85,\n",
|
|
" \"모의 표적 적발률 80% 이상\": decoy_catch >= 0.8,\n",
|
|
" \"자동 승인 0건(전부 판독 대기)\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n",
|
|
" \"식별 건 전량 대기열 등록\": len(queue.items) == int((R.p != \"none\").sum()),\n",
|
|
" \"감사 로그 무결성\": log_ok,\n",
|
|
" \"타일당 200ms 이내\": float(np.mean(ms)) < 200,\n",
|
|
" \"기능 커버리지 85% 이상\": cov >= 0.85,\n",
|
|
"}\n",
|
|
"passed = sum(chk.values())\n",
|
|
"for k, v in chk.items():\n",
|
|
" print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n",
|
|
"print(f\"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n",
|
|
"\n",
|
|
"# ---------------- 5. 전략 ISR 평가 브리핑 요약 ----------------\n",
|
|
"print(f\"\"\"\n",
|
|
"[위성영상 장비 객체분류 평가 브리핑 요약 - 교육용가상자료]\n",
|
|
"1. 처리: 타일 {len(ops)}장 중 {len(res)}장 판독, 손상·오류 {len(rejected)}건 차단({', '.join(rejected)})\n",
|
|
"2. 성능: 종합 정확도 {acc:.1%}, 군용 기종 식별 재현율 {rec:.1%}, 모의 표적 적발 {decoy_catch:.1%}, 타일당 {np.mean(ms):.1f} ms, 전체 {time.perf_counter() - t_start:.0f} s\n",
|
|
"3. 판독 대기: {len(queue.items)}건 (High {int((R[R.p != 'none'].grade == 'High').sum())} / Medium {int((R[R.p != 'none'].grade == 'Medium').sum())} / Low {int((R[R.p != 'none'].grade == 'Low').sum())}), HVT 후보 {sum(i['hvt'] for i in queue.items)}건, 자동 승인 0건\n",
|
|
"4. 신뢰도: High 등급 군용 식별 {len(hi)}건 중 오분류 {hi_err:.1%} -> 등급은 판독 순서 결정용이며 최종 판독은 영상분석관이 한다\n",
|
|
"5. 한계: 합성 32x32 영상 기준 결과로, 실제 위성영상·장비에는 재학습과 재검증이 필요하다. 인접 체계 연동은 판독 승인 결과만 표준 필드로 전달한다.\"\"\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[PASS] 손상 타일·메타데이터 오류 차단\n",
|
|
"[PASS] 처리 타일 298장\n",
|
|
"[PASS] 종합 정확도 90% 이상\n",
|
|
"[PASS] 군용 기종 식별 재현율 85% 이상\n",
|
|
"[PASS] 모의 표적 적발률 80% 이상\n",
|
|
"[PASS] 자동 승인 0건(전부 판독 대기)\n",
|
|
"[PASS] 식별 건 전량 대기열 등록\n",
|
|
"[PASS] 감사 로그 무결성\n",
|
|
"[PASS] 타일당 200ms 이내\n",
|
|
"[PASS] 기능 커버리지 85% 이상\n",
|
|
"자체 점검 10/10 통과, 기능 커버리지 100% (12/12), 파이프라인 실행 성공 예\n",
|
|
"\n",
|
|
"[위성영상 장비 객체분류 평가 브리핑 요약 - 교육용가상자료]\n",
|
|
"1. 처리: 타일 300장 중 298장 판독, 손상·오류 2건 차단(IMG0005, IMG0009)\n",
|
|
"2. 성능: 종합 정확도 96.6%, 군용 기종 식별 재현율 96.0%, 모의 표적 적발 100.0%, 타일당 14.0 ms, 전체 10 s\n",
|
|
"3. 판독 대기: 251건 (High 223 / Medium 21 / Low 7), HVT 후보 51건, 자동 승인 0건\n",
|
|
"4. 신뢰도: High 등급 군용 식별 133건 중 오분류 2.3% -> 등급은 판독 순서 결정용이며 최종 판독은 영상분석관이 한다\n",
|
|
"5. 한계: 합성 32x32 영상 기준 결과로, 실제 위성영상·장비에는 재학습과 재검증이 필요하다. 인접 체계 연동은 판독 승인 결과만 표준 필드로 전달한다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"colab": {
|
|
"provenance": []
|
|
},
|
|
"kernelspec": {
|
|
"display_name": "Python 3",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"name": "python"
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 0
|
|
} |