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

264 KiB

🎯 [TRN03] 드론·미사일 실루엣 이미지 분류 학습앱

Google Colab 실습 환경 (교육·훈련 / Level 2)

Open In Colab


📌 1. 프로젝트 개요 및 배경

항목 내용
과정 분류 교육·훈련 (Level 2)
학습 소요 시간 10개 미션 (5일 / 총 27.5시간)
환경 사양 Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn

📖 프로젝트 목표

러시아-우크라이나 전쟁과 미국-이란 분쟁 사례에서는 소형 드론, 자폭 드론, 순항·탄도미사일이 함께 쓰이면서, 장병이 하늘의 비행체 형상을 보고 위협 여부를 빨리 구분하는 능력이 중요한 기본 전투력이 되었다. 본 프로젝트에서는 실제 기체 사진 대신 사각형·삼각형·원 같은 기본 도형으로 만든 교육용 합성 실루엣을 코드로 생성해, 레이블 체계 정의, 명암·윤곽선·크기 정규화 전처리, 형태 특징 기반 Baseline, 다양한 각도·원거리·흐림 조건의 신경망 분류 고도화, 유사 형상 오분류 방지 신뢰도 점수, 학습 앱 UI, 교관 해설 검토(HITL), 대량 저품질 이미지 부하 시험, AAR 증강 재학습, 최종 패키징까지 실루엣 식별 학습앱을 단계별로 구현한다. 최종 목표는 AI가 문항 출제와 참고 판정을 돕되, 불확실한 판정과 오답 해설은 반드시 교관이 확인한 뒤 장병에게 제공되는 학습 체계를 만드는 것이다.

⚠️ [교육용 가상 데이터셋 원칙]

  • 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 코드 내 난수 기반 가상 데이터로 생성됩니다.
  • 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.

In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn

import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False

print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")

🎯 미션 01. 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축

소요시간: 약 1.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

전술 드론 및 순항/탄도미사일 실루엣 이미지 데이터셋을 구축하고 레이블을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 전술 드론과 순항·탄도미사일 등 비행체 실루엣 이미지 학습 데이터의 레이블 체계와 메타데이터 스키마를 정의하고, 잘못된 데이터를 걸러내는 검증기를 만든다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 민간 쿼드콥터형·민간 고정익형·삼각익 자폭드론형·순항미사일형·탄도미사일형·조류 6종 레이블과 위협 여부, 촬영 조건(기본·원거리·측면·야간·흐림), 센서(EO/IR), 전술 파라미터(기상, 거리 등급, 위치오차, 통신지연), 교관 검수 상태 필드 정의하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: data/schema/trn03_input_schema.json 및 docs/trn03_data_spec.md 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 교육·훈련 실무 및 공통·복합 사례 (러시아-우크라이나·미국-이란) 전장 환경과의 군사적 부합성

미션 PASS 조건

  • 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건

  • 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료

  • 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""전술 드론·순항/탄도미사일 등 비행체 실루엣 이미지(흑백·원거리·다각도) 학습 데이터셋의 레이블 체계와 메타데이터 스키마를 정의하고, 합성 실루엣 샘플을 만들어 검증한다."""
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 1] 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 1] 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# 1) 스키마 정의: 이미지 규격, 레이블(클래스·위협 여부), 촬영 조건, 전술 파라미터, 검수 상태
SCHEMA = {
    "fields": {
        "image_id": {"type": str, "pattern": r"^SIL\d{5}$"},
        "class": {"type": str, "enum": CLASSES},
        "threat": {"type": bool},
        "condition": {"type": str, "enum": list(CONDITIONS)},
        "view": {"type": str, "enum": ["top", "oblique"]},
        "source": {"type": str, "enum": ["synthetic"]},
        "reviewed_by": {"type": str, "pattern": r"^INS\d{2}$"},
        "review_status": {"type": str, "enum": ["draft", "approved"]},
    },
    "sensor": {"type": {"type": str, "enum": ["EO", "IR"]}, "resolution": {"type": list, "length": 2}},
    "tactical": {
        "weather": {"type": str, "enum": ["clear", "haze", "cloud", "night", "rain"]},
        "range_level": {"type": str, "enum": ["near", "mid", "far"]},
        "position_error_m": {"type": (int, float), "min": 0, "max": 500},
        "comm_delay_ms": {"type": (int, float), "min": 0, "max": 10000},
    },
}


def check(name, value, rule):
    if not isinstance(value, rule["type"]) or (isinstance(value, bool) and rule["type"] is not bool):
        return [f"{name}: 자료형 오류({type(value).__name__})"]
    errs = []
    if "enum" in rule and value not in rule["enum"]:
        errs.append(f"{name}: 허용되지 않은 값 '{value}'")
    if "min" in rule and value < rule["min"]:
        errs.append(f"{name}: 최소값 {rule['min']} 미만")
    if "max" in rule and value > rule["max"]:
        errs.append(f"{name}: 최대값 {rule['max']} 초과")
    if "length" in rule and len(value) != rule["length"]:
        errs.append(f"{name}: 길이 {rule['length']} 필요")
    if "pattern" in rule and not re.match(rule["pattern"], value):
        errs.append(f"{name}: 형식 불일치 '{value}'")
    return errs


def validate(rec):
    errs = []
    for k, rule in SCHEMA["fields"].items():
        errs += [f"{k}: 필수 필드 누락"] if k not in rec else check(k, rec[k], rule)
    for g in ("sensor", "tactical"):
        for k, rule in SCHEMA[g].items():
            errs += [f"{g}.{k}: 필수 필드 누락"] if k not in rec.get(g, {}) else check(f"{g}.{k}", rec[g][k], rule)
    px = np.asarray(rec.get("pixels", []), dtype=float)
    if px.shape != (IMG, IMG):
        errs.append(f"pixels: 크기 {IMG}x{IMG} 필요(입력 {px.shape})")
    elif not np.isfinite(px).all() or px.min() < 0 or px.max() > 255:
        errs.append("pixels: 값 범위(0~255) 또는 결측 오류")
    if rec.get("class") in CLASSES and rec.get("threat") != (rec["class"] in THREAT):
        errs.append("threat: 클래스와 위협 여부 불일치")
    return errs


# 2) [교육용가상자료] 클래스별 10장, 총 60장 샘플 생성(기본 + 원거리 + 측면 + 야간 조건)
rng = np.random.default_rng(1)
conds = ["basic", "far", "oblique", "night", "blur"]
weather = {"basic": "clear", "far": "haze", "oblique": "clear", "night": "night", "blur": "rain"}
records = []
for cls in CLASSES:
    for i in range(10):
        cond = conds[i % len(conds)]
        records.append({"image_id": f"SIL{len(records):05d}", "class": cls, "threat": cls in THREAT,
                        "condition": cond, "view": "oblique" if cond == "oblique" else "top", "source": "synthetic",
                        "reviewed_by": "INS01", "review_status": "approved",
                        "sensor": {"type": "IR" if cond == "night" else "EO", "resolution": [IMG, IMG]},
                        "tactical": {"weather": weather[cond], "range_level": "far" if cond == "far" else "mid",
                                     "position_error_m": float(round(rng.uniform(5, 60), 1)), "comm_delay_ms": int(rng.integers(20, 300))},
                        "pixels": draw_cond(cls, cond, rng).tolist()})

ok = sum(1 for r in records if not validate(r))
print(f"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 (목표 오류 0건: {'달성' if ok == len(records) else '미달'})")

# 3) 오류 주입 시험
bad = json.loads(json.dumps(records[0]))
bad["image_id"] = "IMG-7"
bad["class"] = "ufo"
bad["threat"] = "yes"
del bad["tactical"]["comm_delay_ms"]
bad["pixels"] = bad["pixels"][:20]
bad2 = json.loads(json.dumps(records[25]))
bad2["threat"] = False                     # 자폭드론형인데 비위협으로 잘못 표기
bad2["pixels"][3][3] = 300
print("\n[오류 주입 결과]")
for e in validate(bad) + validate(bad2):
    print(" -", e)

# 4) 레이블 정의서와 필드 명세
print("\n[레이블 정의(추상 도형 기준)]")
desc = {"civil_quad": "십자 팔 + 원형 회전날개 4개", "civil_fw": "직선 주익 + 꼬리날개", "attack_fw": "삼각형(델타) 날개, 꼬리 없음",
        "cruise": "가늘고 긴 동체 + 작은 중간 날개", "ballistic": "굵은 원통 동체 + 뾰족한 머리 + 꼬리 핀", "bird": "갈매기형 굽은 날개"}
print(pd.DataFrame({"레이블": CLASSES, "한글명": [KOR[c] for c in CLASSES], "위협": ["예" if c in THREAT else "아니오" for c in CLASSES],
                    "식별 핵심 특징": [desc[c] for c in CLASSES]}).to_string(index=False))
rows = [(g, k, (r["type"].__name__ if isinstance(r["type"], type) else "number"),
         ", ".join(f"{x}={y}" for x, y in r.items() if x != "type")[:60]) for g in ("fields", "sensor", "tactical") for k, r in SCHEMA[g].items()]
print("\n[필드 명세 요약] 필드", len(rows) + 1, "개(pixels 포함), 정의 완성도 100%")
print(pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙"]).to_string(index=False))

# 5) 샘플 실루엣 확인
fig, ax = plt.subplots(len(CLASSES), len(conds), figsize=(8, 9))
for i, cls in enumerate(CLASSES):
    for j, cond in enumerate(conds):
        r = next(x for x in records if x["class"] == cls and x["condition"] == cond)
        ax[i, j].imshow(r["pixels"], cmap="gray", vmin=0, vmax=255); ax[i, j].axis("off")
        ax[i, j].set_title(f"{cls}/{cond}", fontsize=7)
plt.suptitle("Synthetic abstract silhouettes (training data only)")
plt.tight_layout(); plt.show()
💡 [참고] 미션 01 예상 실행 결과 (Output)
정상 샘플 60건 중 검증 통과 60건, 오류 0건 (목표 오류 0건: 달성)

[오류 주입 결과]
 - image_id: 형식 불일치 'IMG-7'
 - class: 허용되지 않은 값 'ufo'
 - threat: 자료형 오류(str)
 - tactical.comm_delay_ms: 필수 필드 누락
 - pixels: 크기 32x32 필요(입력 (20, 32))
 - pixels: 값 범위(0~255) 또는 결측 오류
 - threat: 클래스와 위협 여부 불일치

[레이블 정의(추상 도형 기준)]
       레이블       한글명  위협                 식별 핵심 특징
civil_quad  민간 쿼드콥터형 아니오        십자 팔 + 원형 회전날개 4개
  civil_fw   민간 고정익형 아니오             직선 주익 + 꼬리날개
 attack_fw 삼각익 자폭드론형   예        삼각형(델타) 날개, 꼬리 없음
    cruise    순항미사일형   예      가늘고 긴 동체 + 작은 중간 날개
 ballistic    탄도미사일형   예 굵은 원통 동체 + 뾰족한 머리 + 꼬리 핀
      bird        조류 아니오               갈매기형 굽은 날개

[필드 명세 요약] 필드 15 개(pixels 포함), 정의 완성도 100%
      그룹               필드    자료형                                                           규칙
  fields         image_id    str                                           pattern=^SIL\d{5}$
  fields            class    str enum=['civil_quad', 'civil_fw', 'attack_fw', 'cruise', 'ball
  fields           threat   bool                                                             
  fields        condition    str enum=['basic', 'offcenter', 'far', 'blur', 'lowcon', 'clutte
  fields             view    str                                      enum=['top', 'oblique']
  fields           source    str                                           enum=['synthetic']
  fields      reviewed_by    str                                           pattern=^INS\d{2}$
  fields    review_status    str                                   enum=['draft', 'approved']
  sensor             type    str                                            enum=['EO', 'IR']
  sensor       resolution   list                                                     length=2
tactical          weather    str             enum=['clear', 'haze', 'cloud', 'night', 'rain']
tactical      range_level    str                                  enum=['near', 'mid', 'far']
tactical position_error_m number                                               min=0, max=500
tactical    comm_delay_ms number                                             min=0, max=10000

🎯 미션 02. 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현

소요시간: 약 2.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 흑백·원거리·야간 등 조건이 다른 실루엣 이미지를 같은 기준으로 비교할 수 있도록 명암 대비 강화, 윤곽선 검출, 크기 정규화 전처리 모듈을 구현한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 결측 픽셀 보정, 야간 열상 극성 자동 반전, 잡음 추정값에 따른 중간값 필터 적용(조건별 임계값), 명암 대비 강화, Sobel 윤곽선 검출, 주축 방향·크기 정규화(24x24)를 배치 단위로 구현하고 동작을 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/preprocess/trn03_preprocessor.py 및 전처리 전후 데이터셋 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성

미션 PASS 조건

  • 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%

  • 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과

  • 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""실루엣 이미지의 결측 보정, 야간 극성 보정, 잡음 제거, 명암 대비 강화, 윤곽선(Edge) 검출, 방향·크기 정규화를 일괄 처리하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·전처리 전후 식별 정확도를 확인한다."""
import time
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neighbors import NearestCentroid

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 2] 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 2] 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

warnings.filterwarnings("ignore")

# 1) 처리 속도: 2,400장 일괄 처리
X, y, c = make_dataset(400, 1, tuple(CONDITIONS))
preprocess(X[:50])                                         # 준비 실행
t0 = time.perf_counter()
P = preprocess(X)
dt = time.perf_counter() - t0
err = int((~np.isfinite(P["norm"])).sum())
print(f"전처리 {len(X):,}장, {len(X) / dt:,.0f}장/초 (목표 1,000장/초 이상: {'달성' if len(X) / dt >= 1000 else '미달'}), 출력 오류 {err}건")

# 2) 단위 테스트 5종
tests = {}
tests["T1 출력 크기 24x24 고정"] = P["norm"].shape[1:] == (OUT, OUT)
tests["T2 값 범위 0~1"] = bool(P["norm"].min() >= 0 and P["norm"].max() <= 1)
rng = np.random.default_rng(2)
day = draw("attack_fw", rng, angle=30, scale=.8, fore=1.0, noise=0)
rng = np.random.default_rng(2)
night = draw("attack_fw", rng, angle=30, scale=.8, fore=1.0, noise=0, invert=True)
pd_, pn = preprocess([day, night])["norm"]
tests["T3 야간 반전 영상 극성 자동 보정"] = bool(np.abs(pd_ - pn).mean() < 0.05)
holes = day.astype(float); holes[rng.random(holes.shape) < 0.1] = np.nan
ph = preprocess([holes])
tests["T4 결측 픽셀 보정율 100%"] = bool(ph["missing"][0] > 0 and np.isfinite(ph["norm"]).all())
blank = np.full((IMG, IMG), 200.0)
pb = preprocess([blank])
tests["T5 빈 영상은 예외 없이 '판별 불가' 표시"] = bool(not pb["ok"][0])
for k, v in tests.items():
    print(f"[{'PASS' if v else 'FAIL'}] {k}")
print(f"결측 픽셀 {ph['missing'][0]}개 보정, 단위 테스트 {sum(tests.values())}/5 통과")

# 3) 전처리 전후 식별 정확도(최근접 중심 분류기, 기본 조건으로 학습)
Xtr, ytr, _ = make_dataset(60, 3)
raw = lambda imgs: (np.asarray(imgs, float) / 255).reshape(len(imgs), -1)
nc_raw = NearestCentroid().fit(raw(Xtr), ytr)
nc_pre = NearestCentroid().fit(preprocess(Xtr)["norm"].reshape(len(Xtr), -1), ytr)
rows = []
for cond in ["basic", "offcenter", "night", "lowcon", "noise", "far"]:
    Xt, yt, _ = make_dataset(30, 4, (cond,))
    pt = preprocess(Xt)
    rows.append({"조건": cond, "전처리 전": (nc_raw.predict(raw(Xt)) == yt).mean(),
                 "전처리 후": (nc_pre.predict(pt["norm"].reshape(len(Xt), -1)) == yt).mean(),
                 "잡음필터 적용률": pt["denoised"].mean(), "극성반전 비율": pt["inverted"].mean()})
print("\n[전처리 전후 식별 정확도]")
print(pd.DataFrame(rows).round(3).to_string(index=False))

# 4) 조건별 잡음 임계값 동적 조정 효과: 임계값을 낮춰 모든 영상에 필터를 쓰면 원거리 소형 표적이 손상된다
Xf, yf, _ = make_dataset(30, 4, ("far",))
for t in (NOISE_T, 0.0):
    acc = (nc_pre.predict(preprocess(Xf, noise_t=t)["norm"].reshape(len(Xf), -1)) == yf).mean()
    print(f"원거리 조건, 잡음 임계값 {t:>4}: 정확도 {acc:.3f}")

# 5) 전처리 단계 시각화
idx = [np.flatnonzero(c == k)[0] for k in ["basic", "night", "noise", "lowcon", "far"]]
fig, ax = plt.subplots(4, len(idx), figsize=(10, 8))
for j, i in enumerate(idx):
    for r, (img, name) in enumerate([(X[i], "raw"), (P["enh"][i], "contrast"), (P["edge"][i], "edge"), (P["norm"][i], "normalized 24x24")]):
        ax[r, j].imshow(img, cmap="gray"); ax[r, j].axis("off"); ax[r, j].set_title(f"{c[i]} {name}", fontsize=8)
plt.suptitle("Silhouette preprocessing steps (synthetic)")
plt.tight_layout(); plt.show()
💡 [참고] 미션 02 예상 실행 결과 (Output)
전처리 2,400장, 2,056장/초 (목표 1,000장/초 이상: 달성), 출력 오류 0건
[PASS] T1 출력 크기 24x24 고정
[PASS] T2 값 범위 0~1
[PASS] T3 야간 반전 영상 극성 자동 보정
[PASS] T4 결측 픽셀 보정율 100%
[PASS] T5 빈 영상은 예외 없이 '판별 불가' 표시
결측 픽셀 109개 보정, 단위 테스트 5/5 통과

[전처리 전후 식별 정확도]
       조건  전처리 전  전처리 후  잡음필터 적용률  극성반전 비율
    basic  0.539  0.978       0.0      0.0
offcenter  0.344  0.939       0.0      0.0
    night  0.167  0.983       0.0      1.0
   lowcon  0.322  0.978       0.0      0.0
    noise  0.544  0.911       1.0      0.0
      far  0.194  0.722       0.0      0.0
원거리 조건, 잡음 임계값 12.0: 정확도 0.722
원거리 조건, 잡음 임계값  0.0: 정확도 0.417

🎯 미션 03. 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현

소요시간: 약 2.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 길쭉함·채움률·볼록도·폭 분포 같은 기본 형태 특징으로 비행체 실루엣을 1차 분류하는 Baseline 모델을 구현한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 분할 마스크에서 회전에 무관한 형태 특징 8종을 계산하고 결정나무로 분류 규칙을 학습 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/models/trn03_baseline_model.py 및 기본 추론 결과 보고서 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성

미션 PASS 조건

  • 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성

  • 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성

  • 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""형태 특징 8종(길쭉함·채움률·볼록도·폭 분포 등)으로 비행체 실루엣을 1차 분류하는 결정나무 Baseline을 구현하고, 판단 규칙을 그대로 출력해 투명성을 확인하며 기본 데이터셋 정확도·처리 지연·조건별 한계를 측정한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 3] 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 3] 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

# 1) 학습·검증 데이터(기본 조건) 분리
Xtr, ytr, _ = make_dataset(60, 1)
Xte, yte, _ = make_dataset(40, 2)
_, Ftr, _ = feature_matrix(Xtr)
_, Fte, _ = feature_matrix(Xte)

# 2) 휴리스틱 1차 튜닝: 규칙 깊이(설명 가능한 범위 2~5)별 검증 정확도
print("규칙 깊이  검증 정확도")
for d in [2, 3, 4, 5]:
    m = DecisionTreeClassifier(max_depth=d, random_state=0).fit(Ftr, ytr)
    print(f"{d:>6}     {(m.predict(Fte) == yte).mean():.3f}")
model = DecisionTreeClassifier(max_depth=4, random_state=0).fit(Ftr, ytr)

# 3) 기본 데이터셋 정확도와 처리 지연
t0 = time.perf_counter()
_, F1, _ = feature_matrix(Xte)
pred = model.predict(F1)
ms = (time.perf_counter() - t0) * 1000 / len(Xte)
acc = (pred == yte).mean()
print(f"\n기본 데이터셋 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), 1장당 판단 지연 {ms:.2f} ms")
print(pd.crosstab(pd.Series(yte, name="정답"), pd.Series(pred, name="예측")))

# 4) 판단 규칙(투명성): 교관이 그대로 읽을 수 있는 if-then 규칙
print("\n[판단 규칙 일부]")
rules = export_text(model, feature_names=FEATS, decimals=2).splitlines()
print("\n".join(rules[:12]) + f"\n... (총 {len(rules)}줄)")
imp = pd.Series(model.feature_importances_, index=FEATS).sort_values(ascending=False)
print("중요 특징 상위 3:", ", ".join(f"{k}({v:.2f})" for k, v in imp.head(3).items()))

# 5) 한계: 기본 조건에서 만든 규칙을 어려운 조건에 적용
rows = []
for cond in ["far", "blur", "oblique", "occlude", "combo"]:
    Xh, yh, _ = make_dataset(20, 3, (cond,))
    rows.append({"조건": cond, "정확도": round((model.predict(feature_matrix(Xh)[1]) == yh).mean(), 3)})
print("\n[어려운 조건 정확도(Baseline 한계)]", ", ".join(f"{r['조건']} {r['정확도']}" for r in rows))
print("기본 실행 로그: 학습 360장, 검증 240장, 규칙 깊이 4, 오류 0건")

plt.figure(figsize=(14, 6))
plot_tree(model, feature_names=FEATS, class_names=list(model.classes_), filled=True, fontsize=7, max_depth=3)
plt.title("Baseline decision rules (synthetic silhouettes)")
plt.show()
💡 [참고] 미션 03 예상 실행 결과 (Output)
규칙 깊이  검증 정확도
     2     0.654
     3     0.812
     4     0.946
     5     0.979

기본 데이터셋 정확도 94.6% (목표 75% 이상: 달성), 1장당 판단 지연 1.32 ms
예측          attack_fw  ballistic  bird  civil_fw  civil_quad  cruise
정답                                                                  
attack_fw          40          0     0         0           0       0
ballistic           0         39     1         0           0       0
bird                0          2    38         0           0       0
civil_fw            0          0     0        40           0       0
civil_quad          0          0     0         8          32       0
cruise              0          0     2         0           0      38

[판단 규칙 일부]
|--- w_max <= 0.43
|   |--- end_gap <= 0.10
|   |   |--- class: bird
|   |--- end_gap >  0.10
|   |   |--- class: ballistic
|--- w_max >  0.43
|   |--- fill <= 0.44
|   |   |--- w_max <= 0.64
|   |   |   |--- class: cruise
|   |   |--- w_max >  0.64
|   |   |   |--- fill <= 0.29
|   |   |   |   |--- class: civil_fw
... (총 16줄)
중요 특징 상위 3: w_max(0.41), fill(0.38), end_gap(0.21)

[어려운 조건 정확도(Baseline 한계)] far 0.442, blur 0.45, oblique 0.292, occlude 0.533, combo 0.367
기본 실행 로그: 학습 360장, 검증 240장, 규칙 깊이 4, 오류 0건

🎯 미션 04. 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 다양한 각도·원거리·배경 잡음 환경에서도 실루엣을 정밀하게 분류하도록 소형 신경망 모델로 고도화하고 복합 위협 시나리오로 검증한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 정규화 이미지 576픽셀과 형태 특징 8종을 입력으로 하는 다층 퍼셉트론을 모든 촬영 조건을 섞은 증강 데이터로 학습 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/models/trn03_advanced_detector.py 및 예외 탐지 테스트 결과 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성

미션 PASS 조건

  • 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하

  • 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과

  • 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""다양한 각도·원거리·흐림·잡음·구름·가림·야간 조건을 섞어 학습한 다층 퍼셉트론(소형 신경망) 분류기로 실루엣 분류를 고도화하고, 복합 위협 시나리오 10종에서 위협 재현율·처리 지연·오경보 유발 패턴을 검증한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 4] 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 4] 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----
def to_input(F, N, scaler):
    return np.c_[N, scaler.transform(F) * 0.3]


def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):
    X, y, _ = make_dataset(n_per_class, seed, conds)
    P, F, N = feature_matrix(X)
    scaler = StandardScaler().fit(F)
    model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)
    model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])
    return model, scaler


def predict(model, scaler, images):
    """(전처리 결과, 클래스별 확률 N x 6)을 돌려준다."""
    P, F, N = feature_matrix(images)
    return P, model.predict_proba(to_input(F, N, scaler))

# 1) 학습: 모든 촬영 조건을 섞은 증강 데이터(클래스당 200장)
t0 = time.perf_counter()
model, scaler = train_model(200, seed=5, hidden=(96,))
print(f"신경망 학습 완료 ({time.perf_counter() - t0:.1f} s, 입력 584차원 = 정규화 이미지 576 + 형태 특징 8)")
Xb, yb, _ = make_dataset(60, 1)
tree = DecisionTreeClassifier(max_depth=4, random_state=0).fit(feature_matrix(Xb)[1], yb)   # 미션 3 Baseline

# 2) 복합 위협 시나리오 10종 시험(시나리오당 클래스별 15장)
SCEN = [k for k in CONDITIONS if k != "basic"]
rows, all_true, all_pred = [], [], []
for i, cond in enumerate(SCEN):
    X, y, _ = make_dataset(15, 100 + i, (cond,))
    t0 = time.perf_counter()
    P, prob = predict(model, scaler, X)
    ms = (time.perf_counter() - t0) * 1000 / len(X)
    pred = np.array(CLASSES)[prob.argmax(1)]
    thr = np.isin(y, list(THREAT))
    rec = np.isin(pred[thr], list(THREAT)).mean()
    rows.append({"시나리오": cond, "Baseline 정확도": (tree.predict(feature_matrix(X)[1]) == y).mean(), "신경망 정확도": (pred == y).mean(),
                 "위협 재현율": rec, "장당 ms": ms, "통과": "PASS" if rec >= 0.85 and ms <= 200 else "FAIL"})
    all_true += list(y); all_pred += list(pred)
df = pd.DataFrame(rows)
print(df.round(3).to_string(index=False))
yt, yp = np.array(all_true), np.array(all_pred)
tr = np.isin(yt, list(THREAT))
recall = np.isin(yp[tr], list(THREAT)).mean()
print(f"\n복합 위협 재현율 {recall:.1%} (목표 85% 이상: {'달성' if recall >= 0.85 else '미달'}), 최대 처리 지연 {df['장당 ms'].max():.2f} ms (목표 200 ms 이하)")
print(f"시나리오 통과 {int((df['통과'] == 'PASS').sum())}/10")

# 3) 오경보 유발 패턴: 비위협(민간기·조류)을 위협으로 판정한 경우
fa = pd.Series([f"{t} -> {p}" for t, p in zip(yt, yp) if t not in THREAT and p in THREAT])
print("\n[오경보 유발 패턴 상위]", fa.value_counts().head(3).to_dict())
miss = pd.Series([f"{t} -> {p}" for t, p in zip(yt, yp) if t in THREAT and p not in THREAT])
print("[위협 미탐 패턴 상위]", miss.value_counts().head(3).to_dict())
print("특징 공간 재설계 방향: 원거리·결합 조건에서 날개 끝 형상이 뭉개지므로 폭 분포(end_gap, w_max) 외에 윤곽선 곡률 특징 추가를 검토한다.")

plt.figure(figsize=(9, 4))
x = np.arange(len(df))
plt.bar(x - 0.2, df["Baseline 정확도"], 0.4, label="baseline tree")
plt.bar(x + 0.2, df["신경망 정확도"], 0.4, label="MLP (augmented)")
plt.axhline(0.85, ls="--", c="gray")
plt.xticks(x, df["시나리오"], rotation=30); plt.ylabel("accuracy"); plt.legend()
plt.title("Accuracy by complex scenario (synthetic)"); plt.tight_layout(); plt.show()
💡 [참고] 미션 04 예상 실행 결과 (Output)
신경망 학습 완료 (3.3 s, 입력 584차원 = 정규화 이미지 576 + 형태 특징 8)
     시나리오  Baseline 정확도  신경망 정확도  위협 재현율  장당 ms   통과
offcenter         0.767    1.000   1.000  1.239 PASS
      far         0.467    0.922   0.911  1.131 PASS
     blur         0.400    1.000   1.000  1.201 PASS
   lowcon         0.900    1.000   1.000  1.735 PASS
  clutter         0.556    0.700   0.711  1.153 FAIL
  occlude         0.556    0.789   0.844  1.234 FAIL
  oblique         0.333    0.978   1.000  1.544 PASS
    night         0.967    1.000   1.000  1.207 PASS
    noise         0.778    0.956   1.000  1.178 PASS
    combo         0.311    0.378   0.600  1.319 FAIL

복합 위협 재현율 90.7% (목표 85% 이상: 달성), 최대 처리 지연 1.73 ms (목표 200 ms 이하)
시나리오 통과 7/10

[오경보 유발 패턴 상위] {'bird -> cruise': 9, 'civil_fw -> cruise': 7, 'civil_quad -> cruise': 6}
[위협 미탐 패턴 상위] {'cruise -> bird': 14, 'ballistic -> bird': 11, 'ballistic -> civil_fw': 8}
특징 공간 재설계 방향: 원거리·결합 조건에서 날개 끝 형상이 뭉개지므로 폭 분포(end_gap, w_max) 외에 윤곽선 곡률 특징 추가를 검토한다.

🎯 미션 05. 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

유사 형상 비행체 오분류 방지 신뢰도 점수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 막기 위해 신뢰도 점수와 등급을 산출하는 엔진을 개발한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 분류 확률, 1·2위 확률 차이, 영상 품질(대비·크기·잡음)을 가중 결합해 신뢰도 점수를 만들고 High·Medium·Low 등급을 부여 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/verification/trn03_confidence_engine.py 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성

미션 PASS 조건

  • 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상

  • 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료

  • 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 줄이기 위해, 분류 확률·1/2위 확률 차이·영상 품질을 결합한 신뢰도 점수와 등급(High/Medium/Low)을 산출하고, 오경보율(FAR) 감축·점수 일관성·ROC-AUC·임계값 민감도를 검증한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.metrics import roc_auc_score, roc_curve
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 5] 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 5] 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----
def to_input(F, N, scaler):
    return np.c_[N, scaler.transform(F) * 0.3]


def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):
    X, y, _ = make_dataset(n_per_class, seed, conds)
    P, F, N = feature_matrix(X)
    scaler = StandardScaler().fit(F)
    model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)
    model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])
    return model, scaler


def predict(model, scaler, images):
    """(전처리 결과, 클래스별 확률 N x 6)을 돌려준다."""
    P, F, N = feature_matrix(images)
    return P, model.predict_proba(to_input(F, N, scaler))

WEIGHTS = {"p_max": 0.6, "margin": 0.4}        # 신뢰도 가중치 매트릭스(확률 대 확률 차이)
HIGH, MEDIUM = 0.8, 0.5


def quality(P):
    """영상 품질 0.5~1: 대비가 낮거나, 표적이 작거나, 잡음이 크면 낮아진다."""
    size = P["mask"].sum(axis=(1, 2))
    q = np.clip(P["contrast"] / 120, 0, 1) * 0.4 + np.clip(size / 60, 0, 1) * 0.4 + np.clip(1 - P["noise"] / 40, 0, 1) * 0.2
    return 0.5 + 0.5 * q


def confidence(P, prob):
    s = np.sort(prob, axis=1)
    score = (WEIGHTS["p_max"] * s[:, -1] + WEIGHTS["margin"] * (s[:, -1] - s[:, -2])) * quality(P)
    grade = np.where(score >= HIGH, "High", np.where(score >= MEDIUM, "Medium", "Low"))
    return score, grade


def report(name, alert, y):
    true_thr = np.isin(y, list(THREAT))
    far = (alert & ~true_thr).sum() / max(alert.sum(), 1)
    return {"방식": name, "위협 경보": int(alert.sum()), "오경보": int((alert & ~true_thr).sum()), "오경보율": round(far, 3),
            "위협 재현율": round((alert & true_thr).sum() / true_thr.sum(), 3)}


model, scaler = train_model(200, seed=5, hidden=(96,))
X, y, cond = make_dataset(110, 21, tuple(CONDITIONS))          # 검증용 660장(학습에 쓰지 않음)
P, prob = predict(model, scaler, X)
pred = np.array(CLASSES)[prob.argmax(1)]
score, grade = confidence(P, prob)
is_thr_pred = np.isin(pred, list(THREAT))

before = report("기존(최고 확률 클래스로 단정)", is_thr_pred, y)
after = report("신뢰도 엔진(Low는 교관 확인)", is_thr_pred & (grade != "Low"), y)
print(pd.DataFrame([before, after]).to_string(index=False))
cut = 1 - after["오경보율"] / max(before["오경보율"], 1e-9)
print(f"\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), 교관 확인으로 넘긴 위협 판정 {int((is_thr_pred & (grade == 'Low')).sum())}건")

# 일관성: 같은 이미지에 미세 잡음(밝기 ±1)을 더해 다시 계산했을 때 같은 등급이 나오는 비율
rng = np.random.default_rng(0)
P2, prob2 = predict(model, scaler, np.clip(X + rng.integers(-1, 2, X.shape), 0, 255))
cons = (confidence(P2, prob2)[1] == grade).mean()
print(f"신뢰도 점수 산출 일관성 {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})")

p_threat = prob[:, [CLASSES.index(c) for c in THREAT]].sum(1)
auc = roc_auc_score(np.isin(y, list(THREAT)), p_threat)
print(f"위협 판별 ROC-AUC {auc:.3f}")

print("\n[임계값 민감도: Medium 기준값별]")
for m in [0.3, 0.4, 0.5, 0.6, 0.7]:
    r = report(f"Medium>={m}", is_thr_pred & (score >= m), y)
    print(f"기준 {m}: 오경보율 {r['오경보율']:.3f}, 위협 재현율 {r['위협 재현율']:.3f}")

print("\n[등급 분포: 실제 클래스별]")
print(pd.crosstab(pd.Series(y, name="실제"), pd.Series(grade, name="등급")))
pair = (y == "civil_fw") | (y == "attack_fw") | (y == "civil_quad")
print(f"민간 드론 vs 자폭 드론 쌍 정확도 {(pred[pair] == y[pair]).mean():.1%}, 평균 신뢰도 {score[pair].mean():.2f}")
print("등급은 교육용 판단 보조이며, Low 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다.")

fig, ax = plt.subplots(1, 2, figsize=(11, 4))
fpr, tpr, _ = roc_curve(np.isin(y, list(THREAT)), p_threat)
ax[0].plot(fpr, tpr); ax[0].plot([0, 1], [0, 1], "k--"); ax[0].set_title(f"Threat ROC (AUC={auc:.3f})")
ax[0].set_xlabel("false positive rate"); ax[0].set_ylabel("true positive rate")
for g, col in [("High", "tab:green"), ("Medium", "tab:orange"), ("Low", "tab:red")]:
    ax[1].hist(score[grade == g], bins=15, range=(0, 1), color=col, alpha=0.7, label=g)
ax[1].set_title("Confidence score (uncertainty view)"); ax[1].set_xlabel("score"); ax[1].legend()
plt.tight_layout(); plt.show()
💡 [참고] 미션 05 예상 실행 결과 (Output)
                방식  위협 경보  오경보  오경보율  위협 재현율
 기존(최고 확률 클래스로 단정)    334   29 0.087   0.924
신뢰도 엔진(Low는 교관 확인)    281    5 0.018   0.836

오경보율 감축 79.3% (목표 40% 이상: 달성), 교관 확인으로 넘긴 위협 판정 53건
신뢰도 점수 산출 일관성 97.3% (목표 90% 이상: 달성)
위협 판별 ROC-AUC 0.983

[임계값 민감도: Medium 기준값별]
기준 0.3: 오경보율 0.054, 위협 재현율 0.894
기준 0.4: 오경보율 0.024, 위협 재현율 0.867
기준 0.5: 오경보율 0.018, 위협 재현율 0.836
기준 0.6: 오경보율 0.011, 위협 재현율 0.809
기준 0.7: 오경보율 0.012, 위협 재현율 0.779

[등급 분포: 실제 클래스별]
등급          High  Low  Medium
실제                           
attack_fw     99    5       6
ballistic     72   18      20
bird          48   29      33
civil_fw      73   18      19
civil_quad    90   13       7
cruise        66   27      17
민간 드론 vs 자폭 드론 쌍 정확도 91.2%, 평균 신뢰도 0.86
등급은 교육용 판단 보조이며, Low 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다.

🎯 미션 06. 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현

소요시간: 약 2.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 장병이 실루엣을 보고 비행체를 고르는 모바일·웹 대화형 식별 학습 앱 UI 프로토타입을 구현한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 문항 출제(정답과 혼동하기 쉬운 보기 포함), 응답 채점, 즉시 피드백, 응답 후 AI 참고 판정 표시, 진행률·점수 표시 기능을 구현하고 가상 학습자 응답으로 12문항을 모의 실행 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/ui/trn03_dashboard.py (또는 웹 UI 컴포넌트) 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도

미션 PASS 조건

  • 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내

  • 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동

  • 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""장병이 실루엣을 보고 비행체를 고르는 대화형 식별 학습 앱 UI 프로토타입을 구현한다. 문제 출제·응답 처리·즉시 피드백·AI 참고 판정·진행률 표시를 모의 실행해 화면 갱신 주기와 응답 처리시간을 측정하고, 야간 모드 학습 화면을 그린다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 6] 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 6] 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----
def to_input(F, N, scaler):
    return np.c_[N, scaler.transform(F) * 0.3]


def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):
    X, y, _ = make_dataset(n_per_class, seed, conds)
    P, F, N = feature_matrix(X)
    scaler = StandardScaler().fit(F)
    model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)
    model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])
    return model, scaler


def predict(model, scaler, images):
    """(전처리 결과, 클래스별 확률 N x 6)을 돌려준다."""
    P, F, N = feature_matrix(images)
    return P, model.predict_proba(to_input(F, N, scaler))

N_OPTIONS = 4
CONFUSE = {"civil_fw": "attack_fw", "attack_fw": "civil_fw", "cruise": "ballistic", "ballistic": "cruise", "bird": "civil_fw", "civil_quad": "civil_fw"}


class QuizApp:
    """학습 앱 상태: 문제 은행, 현재 문제, 점수, 응답 이력. 화면 1장 = render() 결과 문자열."""

    def __init__(self, model, scaler, rng, n_questions=12):
        self.model, self.scaler, self.rng = model, scaler, rng
        conds = ["basic", "far", "oblique", "night", "blur", "clutter"]
        self.bank = [(c, conds[i % len(conds)]) for i, c in enumerate(rng.permutation(CLASSES * 2))][:n_questions]
        self.i, self.history = 0, []
        self.next_question()

    def next_question(self):
        cls, cond = self.bank[self.i]
        self.img, self.answer, self.cond = draw_cond(cls, cond, self.rng), cls, cond
        others = [c for c in CLASSES if c not in (cls, CONFUSE[cls])]
        self.options = list(self.rng.permutation([cls, CONFUSE[cls]] + list(self.rng.choice(others, N_OPTIONS - 2, replace=False))))
        self.feedback = None

    def submit(self, choice_no):
        """응답 처리: 채점 -> AI 참고 판정(신뢰도) -> 피드백 문구. 처리시간(ms)을 함께 기록한다."""
        t0 = time.perf_counter()
        choice = self.options[choice_no - 1]
        _, prob = predict(self.model, self.scaler, [self.img])
        ai, conf = CLASSES[int(prob.argmax())], float(prob.max())
        ok = choice == self.answer
        self.feedback = ("정답" if ok else f"오답 - 정답은 {KOR[self.answer]}") + f" | AI 참고 판정: {KOR[ai]}({conf:.0%})"
        ms = (time.perf_counter() - t0) * 1000
        self.history.append({"문항": self.i + 1, "정답": self.answer, "선택": choice, "결과": ok, "조건": self.cond, "처리ms": ms})
        return ms

    def render(self):
        done = len(self.history)
        bar = "#" * done + "." * (len(self.bank) - done)
        score = sum(h["결과"] for h in self.history)
        opts = "  ".join(f"{k + 1}) {KOR[o]}" for k, o in enumerate(self.options))
        return f"[문항 {self.i + 1}/{len(self.bank)}] 진행 {bar} 점수 {score}/{done}\n  보기: {opts}\n  피드백: {self.feedback or '-'}"


model, scaler = train_model(120, seed=5)
rng = np.random.default_rng(8)
app = QuizApp(model, scaler, rng)
skill = {"civil_quad": .9, "civil_fw": .65, "attack_fw": .7, "cruise": .55, "ballistic": .6, "bird": .85}   # 가상 학습자 숙련도

refresh = []
for q in range(len(app.bank)):
    t_screen = time.perf_counter()
    correct = rng.random() < skill[app.answer]
    pick = app.options.index(app.answer if correct else CONFUSE[app.answer]) + 1
    app.submit(pick)
    if q < 2:
        print(app.render() + "\n")
    refresh.append((time.perf_counter() - t_screen) * 1000)
    if q < len(app.bank) - 1:
        app.i += 1; app.next_question()

h = pd.DataFrame(app.history)
print(f"화면 갱신 처리 최대 {max(refresh):.1f} ms (목표: 갱신 주기 1초 이내), 응답 처리 평균 {h['처리ms'].mean():.1f} ms / 최대 {h['처리ms'].max():.1f} ms (목표 100 ms 이내: {'달성' if h['처리ms'].max() <= 100 else '미달'})")
print(f"최종 점수 {h['결과'].sum()}/{len(h)}")
print("\n[클래스별 정답률]")
print(h.groupby("정답")["결과"].mean().rename(index=KOR).round(2).to_string())
print("\n[오답 목록]")
print(h[~h["결과"]][["문항", "정답", "선택", "조건"]].to_string(index=False))

# 학습 화면(야간 모드): 문제 이미지, 보기·피드백, 진행률, 클래스별 정답률, 응답시간 추이 - 시각화 구성요소 5종
plt.style.use("dark_background")
fig = plt.figure(figsize=(12, 7))
ax1 = fig.add_subplot(2, 3, 1); ax1.imshow(app.img, cmap="gray"); ax1.axis("off"); ax1.set_title(f"Q{app.i + 1}: identify the silhouette")
ax2 = fig.add_subplot(2, 3, 2); ax2.axis("off"); ax2.set_title("Options / feedback")
last = app.history[-1]
for k, o in enumerate(app.options):
    col = "lime" if o == app.answer else ("red" if o == last["선택"] else "white")
    ax2.text(0.05, 0.85 - k * 0.2, f"{k + 1}) {o}", color=col, fontsize=13, fontweight="bold" if col != "white" else "normal")
ax3 = fig.add_subplot(2, 3, 3); s = h["결과"].sum()
ax3.barh([0], [len(h)], color="dimgray"); ax3.barh([0], [s], color="deepskyblue"); ax3.set_yticks([])
ax3.set_title(f"Progress / score {s}/{len(h)}")
ax4 = fig.add_subplot(2, 3, 4); acc = h.groupby("정답")["결과"].mean()
ax4.bar(acc.index, acc.values, color=["red" if v < 0.6 else "deepskyblue" for v in acc.values]); ax4.tick_params(axis="x", rotation=40)
ax4.set_title("Accuracy by class (red = weak)")
ax5 = fig.add_subplot(2, 3, 5); ax5.plot(h["문항"], h["처리ms"], "o-", color="orange"); ax5.axhline(100, ls="--", c="gray")
ax5.set_title("Response time (ms)"); ax5.set_xlabel("question")
ax6 = fig.add_subplot(2, 3, 6); ax6.axis("off")
ax6.text(0, 0.8, "Night mode UI\nCorrect: green / Wrong: red\nAI hint shown only after answer", fontsize=11, va="top")
plt.suptitle("Silhouette ID training app prototype - training data only"); plt.tight_layout(); plt.show()
💡 [참고] 미션 06 예상 실행 결과 (Output)
[문항 1/12] 진행 #........... 점수 1/1
  보기: 1) 조류  2) 민간 고정익형  3) 민간 쿼드콥터형  4) 삼각익 자폭드론형
  피드백: 정답 | AI 참고 판정: 민간 고정익형(96%)

[문항 2/12] 진행 ##.......... 점수 1/2
  보기: 1) 탄도미사일형  2) 삼각익 자폭드론형  3) 순항미사일형  4) 민간 고정익형
  피드백: 오답 - 정답은 순항미사일형 | AI 참고 판정: 순항미사일형(83%)

화면 갱신 처리 최대 8.5 ms (목표: 갱신 주기 1초 이내), 응답 처리 평균 4.4 ms / 최대 6.6 ms (목표 100 ms 이내: 달성)
최종 점수 9/12

[클래스별 정답률]
정답
삼각익 자폭드론형    1.0
탄도미사일형       0.5
조류           1.0
민간 고정익형      1.0
민간 쿼드콥터형     1.0
순항미사일형       0.0

[오답 목록]
 문항        정답        선택      조건
  2    cruise ballistic     far
  6 ballistic    cruise clutter
 10    cruise ballistic   night

🎯 미션 07. 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

학습자 오답 시 정답 실루엣 비교 및 핵심 특징 해설 HITL 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 학습자가 오답을 냈을 때 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징을 해설하되, 교관 검토를 거쳐야만 공개되는 HITL 해설 모듈을 설계한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 오답을 혼동 쌍 단위로 묶어 AI 해설 초안(날개 형상 등 특징 차이)을 만들고 검토 대기열에 등록 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/hitl/trn03_approval_workflow.py 및 docs/trn03_hitl_guide.md 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성

미션 PASS 조건

  • 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%

  • 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증

  • 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""학습자가 오답을 내면 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징(날개 형상 등) 해설 초안을 만들고, 교관이 승인·수정·반려해야만 학습자에게 공개되는 인간 검토(HITL) 해설 모듈을 구현한다. 모든 처리는 위·변조 확인이 가능한 감사 로그에 남는다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 7] 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 7] 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

FEAT_KOR = {"elong": "길쭉함", "fill": "채움률", "solidity": "볼록도", "n_parts": "분리 부분 수", "end_gap": "앞뒤 폭 차이",
            "mid_bulge": "중앙부 날개 돌출", "w_max": "최대 폭(날개 폭)", "compact": "윤곽 복잡도"}
KEY = {"civil_quad": "원형 회전날개 4개", "civil_fw": "직선 주익과 꼬리날개", "attack_fw": "꼬리 없는 삼각형 날개",
       "cruise": "가는 동체와 작은 중간 날개", "ballistic": "굵은 동체·뾰족한 머리·꼬리 핀", "bird": "갈매기형으로 굽은 날개"}
ACTIONS = {"APPROVE": "승인(공개)", "EDIT": "수정 후 승인", "REJECT": "반려(재작성)"}
SLA_H = 24


def template(cls):
    return draw(cls, np.random.default_rng(0), angle=0, scale=.85, fore=1.0, blur=0, noise=0, shift=0)


TEMPL = {c: template(c) for c in CLASSES}
TF = {c: shape_features(preprocess([TEMPL[c]])["mask"][0]) for c in CLASSES}
SPREAD = {k: np.std([TF[c][k] for c in CLASSES]) + 1e-6 for k in FEATS}


def draft(answer, chosen):
    """AI 해설 초안: 두 클래스 기준 실루엣의 형태 특징 차이 상위 3개를 문장으로 만든다(초안일 뿐 공개하지 않음)."""
    diff = sorted(FEATS, key=lambda k: -abs(TF[answer][k] - TF[chosen][k]) / SPREAD[k])[:3]
    parts = [f"{FEAT_KOR[k]} {TF[answer][k]:.2f} vs {TF[chosen][k]:.2f}" for k in diff]
    return f"정답 {KOR[answer]}은(는) {KEY[answer]}, 선택한 {KOR[chosen]}은(는) {KEY[chosen]}. 차이: " + "; ".join(parts)


class ReviewQueue:
    def __init__(self):
        self.items, self.log, self.clock = {}, [], 0.0

    def _record(self, body):
        prev = self.log[-1]["hash"] if self.log else "GENESIS"
        body = {**body, "t_h": self.clock}
        h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()
        self.log.append({**body, "prev": prev, "hash": h})

    def verify(self):
        prev = "GENESIS"
        for n, r in enumerate(self.log, 1):
            body = {k: v for k, v in r.items() if k not in ("prev", "hash")}
            if r["prev"] != prev or r["hash"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():
                return False, n
            prev = r["hash"]
        return True, -1

    def submit(self, learner, answer, chosen, freq):
        key = f"{answer}->{chosen}"
        if key in self.items and self.items[key]["status"] == "PENDING":      # 같은 혼동 쌍은 한 건으로 묶고 빈도만 올린다
            self.items[key]["learners"].append(learner); self.items[key]["freq"] = freq
        else:
            self.items[key] = {"key": key, "answer": answer, "chosen": chosen, "draft": draft(answer, chosen), "status": "PENDING",
                               "published": None, "learners": [learner], "freq": freq, "t_in": self.clock, "escalated": False}
        self._record({"event": "SUBMIT", "key": key, "learner": learner, "by": "AI"})

    def order(self):
        todo = [i for i in self.items.values() if i["status"] == "PENDING"]
        return sorted(todo, key=lambda i: (not i["escalated"], -i["freq"], i["t_in"]))

    def decide(self, key, action, instructor, reason, text=None, feedback=""):
        it = self.items.get(key)
        if it is None or it["status"] != "PENDING":
            raise ValueError("검토 대기 중인 항목이 아님")
        if action not in ACTIONS or not instructor.startswith("INS"):
            raise ValueError("허용되지 않은 조치 또는 교관 ID 없음")
        if len(reason.strip()) < 5:
            raise ValueError("판단 근거 5자 이상 필요")
        if action == "EDIT" and not text:
            raise ValueError("수정 승인은 수정 해설문 필요")
        it["status"] = {"APPROVE": "APPROVED", "EDIT": "APPROVED", "REJECT": "REJECTED"}[action]
        it["published"] = (text or it["draft"]) if action != "REJECT" else None
        it["wait_h"] = self.clock - it["t_in"]
        self._record({"event": "DECIDE", "key": key, "action": action, "by": instructor, "reason": reason, "feedback": feedback})

    def check_sla(self):
        out = []
        for i in self.items.values():
            if i["status"] == "PENDING" and not i["escalated"] and self.clock - i["t_in"] > SLA_H:
                i["escalated"] = True; out.append(i["key"])
                self._record({"event": "ESCALATE", "key": i["key"], "to": "교육훈련 담당관"})
        return out


# [교육용가상자료] 학습자 20명의 오답 기록(가명 L01~L20)
rng = np.random.default_rng(4)
pairs = [("attack_fw", "civil_fw")] * 6 + [("cruise", "ballistic")] * 5 + [("civil_fw", "attack_fw")] * 3 + [("bird", "civil_fw")] * 2 + [("ballistic", "cruise")] * 4
q = ReviewQueue()
freq = {}
for n, (a, c) in enumerate(rng.permutation(np.array(pairs, dtype=object))):
    freq[(a, c)] = freq.get((a, c), 0) + 1
    q.clock += 0.5
    q.submit(f"L{n + 1:02d}", a, c, freq[(a, c)])
print(f"오답 {len(pairs)}건 -> 해설 검토 항목 {len(q.items)}건 (혼동 쌍 단위), 공개된 해설 {sum(i['published'] is not None for i in q.items.values())}건")
print("검토 순서(빈도순):", [f"{i['key']}({i['freq']})" for i in q.order()])
print("\n[AI 해설 초안 예시 - 교관 검토 전 비공개]\n" + q.order()[0]["draft"])

for args in [("attack_fw->civil_fw", "APPROVE", "", "근거 충분"), ("attack_fw->civil_fw", "EDIT", "INS01", "표현 보완 필요"), ("cruise->ballistic", "PUBLISH", "INS01", "자동 공개")]:
    try:
        q.decide(*args)
    except ValueError as e:
        print("차단:", e)

q.clock += 2; q.decide("attack_fw->civil_fw", "EDIT", "INS01", "꼬리날개 유무를 먼저 보도록 수정",
                       text="꼬리날개가 없고 날개가 삼각형이면 자폭드론형, 직선 날개와 꼬리날개가 있으면 민간 고정익형이다.", feedback="원거리 예시 추가 요망")
q.clock += 3; q.decide("cruise->ballistic", "APPROVE", "INS02", "중간 날개 유무 설명이 정확함")
q.clock += 1; q.decide("bird->civil_fw", "REJECT", "INS02", "조류 날갯짓 변형 설명 누락, 재작성")
q.clock += 30
print("SLA 초과 상신:", q.check_sla())
q.clock += 1; q.decide("ballistic->cruise", "APPROVE", "INS01", "몸통 굵기 비교가 명확함")

df = pd.DataFrame(q.items.values())
expected = len(pairs) + int((df["status"] != "PENDING").sum()) + int(df["escalated"].sum())   # 제출 + 결정 + 상신 이벤트 수
print("남은 검토 대기:", [f"{i['key']}(대기 {q.clock - i['t_in']:.1f}h)" for i in q.order()])
print("\n" + df[["key", "freq", "status", "escalated", "wait_h"]].fillna("-").to_string(index=False))
print(f"\n자동 공개 0건(승인 없이 공개된 해설 {sum(1 for i in q.items.values() if i['published'] and i['status'] != 'APPROVED')}건), "
      f"감사 로그 {len(q.log)}건, 로깅 성공률 {len(q.log) / expected:.0%}, 해시 체인 검증 {q.verify()}")
q.log[3]["learner"] = "L99"
print("4번째 기록 변조 후 검증(False, 깨진 위치):", q.verify())
print("교관 피드백 입력 항목: 교관 ID, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견")

a, c = "attack_fw", "civil_fw"
fig, ax = plt.subplots(1, 3, figsize=(10, 3.6))
ax[0].imshow(TEMPL[a], cmap="gray"); ax[0].set_title(f"correct: {a}")
ax[1].imshow(TEMPL[c], cmap="gray"); ax[1].set_title(f"chosen: {c}")
ax[2].imshow((TEMPL[c].astype(float) - TEMPL[a]), cmap="coolwarm"); ax[2].set_title("difference (red = only correct)")
for x in ax:
    x.axis("off")
plt.suptitle("Wrong-answer comparison for instructor review (synthetic)"); plt.tight_layout(); plt.show()
💡 [참고] 미션 07 예상 실행 결과 (Output)
오답 20건 -> 해설 검토 항목 5건 (혼동 쌍 단위), 공개된 해설 0건
검토 순서(빈도순): ['attack_fw->civil_fw(6)', 'cruise->ballistic(5)', 'ballistic->cruise(4)', 'civil_fw->attack_fw(3)', 'bird->civil_fw(2)']

[AI 해설 초안 예시 - 교관 검토 전 비공개]
정답 삼각익 자폭드론형은(는) 꼬리 없는 삼각형 날개, 선택한 민간 고정익형은(는) 직선 주익과 꼬리날개. 차이: 윤곽 복잡도 3.82 vs 7.95; 볼록도 0.86 vs 0.40; 채움률 0.51 vs 0.29
차단: 허용되지 않은 조치 또는 교관 ID 없음
차단: 수정 승인은 수정 해설문 필요
차단: 허용되지 않은 조치 또는 교관 ID 없음
SLA 초과 상신: ['civil_fw->attack_fw', 'ballistic->cruise']
남은 검토 대기: ['civil_fw->attack_fw(대기 46.0h)']

                key  freq   status  escalated wait_h
attack_fw->civil_fw     6 APPROVED      False   11.5
civil_fw->attack_fw     3  PENDING       True      -
  cruise->ballistic     5 APPROVED      False   13.5
  ballistic->cruise     4 APPROVED       True   44.5
     bird->civil_fw     2 REJECTED      False   10.0

자동 공개 0건(승인 없이 공개된 해설 0건), 감사 로그 26건, 로깅 성공률 100%, 해시 체인 검증 (True, -1)
4번째 기록 변조 후 검증(False, 깨진 위치): (False, 4)
교관 피드백 입력 항목: 교관 ID, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견

🎯 미션 08. 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증

소요시간: 약 3.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

흐린 저품질 실루엣 이미지 대량 분류 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 다량의 흐리고 품질이 낮은 실루엣 이미지가 한꺼번에 들어올 때 분류 서비스가 멈추지 않고 안전하게 동작하는지 한계치를 검증한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 평시 100장에서 500% 증가한 600장까지 요청을 늘리고, 손상 입력과 극저화질 영상을 섞어 시스템 다운·결과 유실 여부 확인하고 유효성을 점검하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: tests/trn03_stress_test.py 및 스트레스 테스트 분석 보고서 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성

미션 PASS 조건

  • 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%

  • 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료

  • 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""흐리고 품질이 낮은 실루엣 이미지를 평시의 최대 6배(500% 증가)까지 한꺼번에 넣고 손상 입력을 섞어, 분류 서비스가 멈추지 않는지(다운 0건)·결과가 빠지지 않는지(유실률 0%)·흐림 한계치·처리량·메모리·병목 구간을 검증한다."""
import time
import tracemalloc
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 8] 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 8] 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----
def to_input(F, N, scaler):
    return np.c_[N, scaler.transform(F) * 0.3]


def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):
    X, y, _ = make_dataset(n_per_class, seed, conds)
    P, F, N = feature_matrix(X)
    scaler = StandardScaler().fit(F)
    model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)
    model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])
    return model, scaler


def predict(model, scaler, images):
    """(전처리 결과, 클래스별 확률 N x 6)을 돌려준다."""
    P, F, N = feature_matrix(images)
    return P, model.predict_proba(to_input(F, N, scaler))

CAPACITY = 200          # 한 번에 처리하는 최대 장수(초과 시 대기열로 나눠 처리 = 성능저하 모드)
MIN_CONF = 0.5          # 이보다 낮으면 정답을 단정하지 않고 '판별 불가(교관 확인)'


def serve(requests, stage):
    """요청 전부에 결과를 하나씩 돌려준다. 손상 입력은 '입력 오류', 저신뢰는 '판별 불가'로 안전하게 처리한다."""
    results, valid = {}, []
    for rid, img in requests:
        a = np.asarray(img, dtype=float) if img is not None else None
        if a is None or a.shape != (IMG, IMG) or np.isnan(a).mean() > 0.5:
            results[rid] = "입력 오류(재전송 요청)"
        else:
            valid.append((rid, a))
    for k in range(0, len(valid), CAPACITY):            # 대기열 청크 처리
        ids, imgs = zip(*valid[k:k + CAPACITY])
        t0 = time.perf_counter(); P = preprocess(np.array(imgs)); t1 = time.perf_counter()
        F = np.array([[d[f] for f in FEATS] for d in map(shape_features, P["mask"])]); t2 = time.perf_counter()
        prob = model.predict_proba(to_input(F, P["norm"].reshape(len(F), -1), scaler)); t3 = time.perf_counter()
        stage["전처리"] += t1 - t0; stage["형태특징"] += t2 - t1; stage["추론"] += t3 - t2
        for rid, ok, p in zip(ids, P["ok"], prob):
            results[rid] = CLASSES[int(p.argmax())] if ok and p.max() >= MIN_CONF else "판별 불가(교관 확인)"
    return results


def load_case(n, seed, blur=(1.2, 2.2), corrupt=0.0):
    rng = np.random.default_rng(seed)
    y = rng.choice(CLASSES, n)
    reqs = [(f"R{i:05d}", draw(c, rng, blur=rng.uniform(*blur), contrast=rng.uniform(.5, 1), noise=10)) for i, c in enumerate(y)]
    for i in rng.choice(n, int(n * corrupt), replace=False):
        reqs[i] = (reqs[i][0], [None, np.full((IMG, IMG), np.nan), np.zeros((20, 20))][i % 3])
    return reqs, dict(zip([r[0] for r in reqs], y))


model, scaler = train_model(120, seed=5)
rows, stage = [], {"전처리": 0.0, "형태특징": 0.0, "추론": 0.0}
for name, n, kw in [("평시 100장", 100, {}), ("300% 증가 400장", 400, {}), ("500% 증가 600장", 600, {}),
                    ("500% + 손상 입력 3%", 600, {"corrupt": .03}), ("500% + 극저화질(흐림 3.0)", 600, {"blur": (2.8, 3.2)})]:
    reqs, truth = load_case(n, len(rows) + 1, **kw)
    t0 = time.perf_counter()
    try:
        res, down = serve(reqs, stage), 0
    except Exception as e:
        res, down = {}, 1; print("다운:", e)
    dt = time.perf_counter() - t0
    judged = [r for r, v in res.items() if v in CLASSES]
    rows.append({"상황": name, "요청": n, "응답": len(res), "유실률": 1 - len(res) / n, "다운": down,
                 "판별불가": sum(v.startswith("판별") for v in res.values()), "입력오류": sum(v.startswith("입력") for v in res.values()),
                 "판별 정확도": np.mean([res[r] == truth[r] for r in judged]) if judged else 0, "처리량(장/s)": int(n / dt),
                 "성능저하 모드": f"청크 {int(np.ceil(n / CAPACITY))}회" if n > CAPACITY else "-"})
df = pd.DataFrame(rows)
print(df.round(3).to_string(index=False))
print(f"\n시스템 다운 {df['다운'].sum()}건, 최대 유실률 {df['유실률'].max():.0%} (목표 다운 0건·유실 0%: {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == 0 else '미달'})")

# 흐림 한계치: 흐림 정도를 올리며 정확도가 75% 아래로 떨어지는 지점
print("\n흐림(sigma)  판별 정확도  판별불가 비율")
limit = None
for b in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5]:
    reqs, truth = load_case(150, 50, blur=(b, b))
    res = serve(reqs, {"전처리": 0, "형태특징": 0, "추론": 0})
    judged = [r for r, v in res.items() if v in CLASSES]
    acc = np.mean([res[r] == truth[r] for r in judged])
    if limit is None and acc < 0.75:
        limit = b
    print(f"{b:>8}    {acc:.3f}       {1 - len(judged) / len(res):.3f}")
print(f"한계치: 흐림 {limit} 이상에서 정확도 75% 미만 -> 해당 영상은 학습 문항에서 제외하거나 '고난도'로 분류")

# 메모리 누수 확인: 같은 부하를 3회 반복해 메모리 증가량 측정
tracemalloc.start()
reqs, _ = load_case(300, 99)
mem = []
for _ in range(3):
    serve(reqs, {"전처리": 0, "형태특징": 0, "추론": 0}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)
peak = tracemalloc.get_traced_memory()[1] / 1e6
tracemalloc.stop()
print(f"반복 후 메모리 {', '.join(f'{m:.1f}' for m in mem)} MB (최대 {peak:.1f} MB), 증가 없음: {mem[-1] - mem[0] < 1}")
tot = sum(stage.values())
print("병목 구간:", ", ".join(f"{k} {v / tot:.0%}" for k, v in sorted(stage.items(), key=lambda x: -x[1])), "-> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다")

plt.figure(figsize=(8, 4))
plt.bar(range(len(df)), df["처리량(장/s)"], color=["tab:red" if m != "-" else "tab:blue" for m in df["성능저하 모드"]])
plt.xticks(range(len(df)), [f"case{i + 1}" for i in range(len(df))]); plt.ylabel("images / s")
plt.title("Stress test throughput (red = queued/degraded mode)"); plt.show()
💡 [참고] 미션 08 예상 실행 결과 (Output)
                 상황  요청  응답  유실률  다운  판별불가  입력오류  판별 정확도  처리량(장/s) 성능저하 모드
            평시 100장 100 100  0.0   0    37     0   0.889       731       -
       300% 증가 400장 400 400  0.0   0   155     0   0.918       706   청크 2회
       500% 증가 600장 600 600  0.0   0   221     0   0.918       707   청크 3회
    500% + 손상 입력 3% 600 600  0.0   0   216    18   0.915       802   청크 3회
500% + 극저화질(흐림 3.0) 600 600  0.0   0   366     0   0.654       660   청크 3회

시스템 다운 0건, 최대 유실률 0% (목표 다운 0건·유실 0%: 달성)

흐림(sigma)  판별 정확도  판별불가 비율
     0.5    1.000       0.020
     1.0    0.992       0.167
     1.5    0.937       0.367
     2.0    0.825       0.467
     2.5    0.710       0.587
     3.0    0.566       0.647
     3.5    0.462       0.653
한계치: 흐림 2.5 이상에서 정확도 75% 미만 -> 해당 영상은 학습 문항에서 제외하거나 '고난도'로 분류
반복 후 메모리 0.4, 0.4, 0.4 MB (최대 30.4 MB), 증가 없음: True
병목 구간: 형태특징 68%, 전처리 31%, 추론 1% -> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다

🎯 미션 09. AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습

소요시간: 약 3.0시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터를 증강 재학습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, AAR 사후검토로 장병의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 분석하고, 취약 데이터를 증강해 재학습한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 가상 장병 40명의 학습 기록에서 다빈도 오답 쌍을 찾고, 기본 조건 중심으로 학습한 현행 모델의 오류를 조건·클래스별로 분류 수행하고 검증하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: docs/trn03_aar_report.md 및 configs/trn03_tuned_params.yaml 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성

미션 PASS 조건

  • 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건

  • 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과

  • 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""AAR(사후검토) 형식으로 장병 학습 기록의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 원인별로 분석하고, 취약 조건·클래스를 증강한 데이터로 재학습해 종합 정확도 향상·재발 오류·회귀 여부와 재보정 파라미터를 산출한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 9] AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 9] AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----
def to_input(F, N, scaler):
    return np.c_[N, scaler.transform(F) * 0.3]


def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):
    X, y, _ = make_dataset(n_per_class, seed, conds)
    P, F, N = feature_matrix(X)
    scaler = StandardScaler().fit(F)
    model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)
    model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])
    return model, scaler


def predict(model, scaler, images):
    """(전처리 결과, 클래스별 확률 N x 6)을 돌려준다."""
    P, F, N = feature_matrix(images)
    return P, model.predict_proba(to_input(F, N, scaler))


def fit(X, y):
    P, F, N = feature_matrix(X)
    sc = StandardScaler().fit(F)
    m = MLPClassifier((96,), max_iter=200, early_stopping=True, random_state=0).fit(to_input(F, N, sc), [CLASSES.index(v) for v in y])
    return m, sc


def evaluate(m, sc, X):
    return np.array(CLASSES)[predict(m, sc, X)[1].argmax(1)]


# [1. 무엇이 일어났나] 장병 학습 기록(가상 40명 x 30문항) 다빈도 오답
rng = np.random.default_rng(3)
CONF = {"civil_fw": ("attack_fw", .30), "attack_fw": ("civil_fw", .25), "cruise": ("ballistic", .40), "ballistic": ("cruise", .35),
        "bird": ("civil_fw", .15), "civil_quad": ("civil_fw", .05)}
log = []
for s in range(40):
    for q in range(30):
        c = CLASSES[rng.integers(6)]
        wrong, p = CONF[c]
        log.append({"장병": f"L{s + 1:02d}", "정답": c, "응답": wrong if rng.random() < p else c})
lg = pd.DataFrame(log)
err = lg[lg["정답"] != lg["응답"]]
print(f"[1. 무엇이 일어났나] 응답 {len(lg)}건 중 오답 {len(err)}건({len(err) / len(lg):.1%})")
print("다빈도 오답 쌍:", (err["정답"] + "->" + err["응답"]).value_counts().head(3).to_dict())

# [2. 왜 일어났나] AI 분류기(현행: 기본·회전·저대비·야간 조건 중심 학습) 오류를 조건별로 분류
base_conds = ("basic", "offcenter", "lowcon", "night")
Xtr, ytr, _ = make_dataset(150, 5, base_conds)
m0, s0 = fit(Xtr, ytr)
Xev, yev, cev = make_dataset(110, 31, tuple(CONDITIONS))           # AAR 평가 세트(11개 조건)
p0 = evaluate(m0, s0, Xev)
acc0 = (p0 == yev).mean()
by = pd.Series(p0 != yev).groupby(cev).mean().sort_values(ascending=False)
print(f"\n[2. 왜 일어났나] 현행 모델 종합 정확도 {acc0:.3f}, 오류율 상위 조건:", by.head(4).round(2).to_dict())
weak_cls = pd.Series(p0 != yev).groupby(yev).mean().sort_values(ascending=False)
print("오류율 상위 클래스:", weak_cls.head(3).round(2).to_dict(), "-> 원인: 학습에 원거리·흐림·가림·측면 시점 영상이 없음")

# [3. 무엇을 바꾸나] 취약 조건 증강 + 다빈도 오답 클래스 가중 증강 후 재학습
weak_conds = tuple(by.index[by > 0.15]) or tuple(CONDITIONS)
Xa, ya, _ = make_dataset(110, 6, weak_conds)
extra = [c for c in ("cruise", "ballistic", "civil_fw", "attack_fw")]
rng2 = np.random.default_rng(7)
Xb = np.array([draw_cond(c, weak_conds[i % len(weak_conds)], rng2) for i, c in enumerate(extra * 40)])
yb = np.array(extra * 40)
m1, s1 = fit(np.r_[Xtr, Xa, Xb], np.r_[ytr, ya, yb])
prob1 = predict(m1, s1, Xev)[1]
p1 = np.array(CLASSES)[prob1.argmax(1)]
acc1 = (p1 == yev).mean()
print(f"\n[3. 무엇을 바꾸나] 증강 조건 {list(weak_conds)}, 혼동 클래스 4종 각 40장 추가")
print(f"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.10 else '미달'})")

# 회귀 테스트: AAR 등록 오류의 재발, 기존 정답의 회귀
reg = p0 != yev
again = reg & (p1 != yev)
print(f"AAR 등록 오류 {reg.sum()}건 중 재발 {again.sum()}건(목표 0건: {'달성' if again.sum() == 0 else '미달'}), 기존 정답 중 새로 틀린 회귀 {(~reg & (p1 != yev)).sum()}건")
print(f"재발 오류 중 신뢰도 0.5 미만이라 '교관 확인'으로 걸러지는 건 {(again & (prob1.max(1) < 0.5)).sum()}건, 단정 오답으로 남는 건 {(again & (prob1.max(1) >= 0.5)).sum()}건")
comp = pd.DataFrame({"재보정 전": pd.Series(p0 == yev).groupby(cev).mean(), "재보정 후": pd.Series(p1 == yev).groupby(cev).mean()}).round(3)
print(comp.T.to_string())
print("남은 재발 오류는 결합(combo)·구름 조건에 몰려 있어 교관 확인 대상으로 두고, 다음 주기에 지속 학습으로 보완한다.")

tuned = (f"augment_conditions: {list(weak_conds)}\naugment_extra_per_class: {{cruise: 40, ballistic: 40, civil_fw: 40, attack_fw: 40}}\n"
         f"min_confidence: 0.5\nquiz_mix: {{confusing_pairs: 0.4, weak_conditions: 0.3, random: 0.3}}\nhidden_layer: 96")
print("\n[재보정 파라미터(trn03_tuned_params.yaml 내용)]\n" + tuned)

plt.figure(figsize=(9, 4))
x = np.arange(len(comp))
plt.bar(x - 0.2, comp["재보정 전"], 0.4, label="before AAR"); plt.bar(x + 0.2, comp["재보정 후"], 0.4, label="after augmentation")
plt.xticks(x, comp.index, rotation=30); plt.ylabel("accuracy"); plt.legend(); plt.title("AAR retraining effect by condition (synthetic)")
plt.tight_layout(); plt.show()
💡 [참고] 미션 09 예상 실행 결과 (Output)
[1. 무엇이 일어났나] 응답 1200건 중 오답 337건(28.1%)
다빈도 오답 쌍: {'cruise->ballistic': 90, 'civil_fw->attack_fw': 77, 'attack_fw->civil_fw': 66}

[2. 왜 일어났나] 현행 모델 종합 정확도 0.770, 오류율 상위 조건: {'combo': 0.67, 'oblique': 0.62, 'occlude': 0.42, 'clutter': 0.38}
오류율 상위 클래스: {'cruise': 0.34, 'bird': 0.3, 'civil_fw': 0.27} -> 원인: 학습에 원거리·흐림·가림·측면 시점 영상이 없음

[3. 무엇을 바꾸나] 증강 조건 ['combo', 'oblique', 'occlude', 'clutter', 'far'], 혼동 클래스 4종 각 40장 추가
종합 정확도 0.770 -> 0.873 (+10.3%p, 목표 +10%p: 달성)
AAR 등록 오류 152건 중 재발 67건(목표 0건: 미달), 기존 정답 중 새로 틀린 회귀 17건
재발 오류 중 신뢰도 0.5 미만이라 '교관 확인'으로 걸러지는 건 36건, 단정 오답으로 남는 건 31건
       basic   blur  clutter  combo    far  lowcon  night  noise  oblique  occlude  offcenter
재보정 전    1.0  0.933    0.617  0.333  0.683     1.0  0.983  0.967    0.383    0.583      0.983
재보정 후    1.0  0.967    0.650  0.450  0.867     1.0  1.000  0.950    0.967    0.750      1.000
남은 재발 오류는 결합(combo)·구름 조건에 몰려 있어 교관 확인 대상으로 두고, 다음 주기에 지속 학습으로 보완한다.

[재보정 파라미터(trn03_tuned_params.yaml 내용)]
augment_conditions: ['combo', 'oblique', 'occlude', 'clutter', 'far']
augment_extra_per_class: {cruise: 40, ballistic: 40, civil_fw: 40, attack_fw: 40}
min_confidence: 0.5
quiz_mix: {confusing_pairs: 0.4, weak_conditions: 0.3, random: 0.3}
hidden_layer: 96

🎯 미션 10. 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성

소요시간: 약 3.5시간

📋 미션 배경 및 수행 지침

미션 배경 및 당위성

드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, 실루엣 생성부터 전처리, 분류, 신뢰도 등급, 퀴즈 운영, 교관 해설 검토까지 학습앱 전체를 통합하고 부대 교육 배포 가이드를 완성한다. 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.

이 문제를 해결하기 위해 본 미션에서는 AAR 재보정값을 적용한 통합 코드를 한 번에 실행해 손상 문항이 자동 차단되는지 확인하고 유효성을 점검하는 실습을 체계적으로 수행합니다.

학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.

이번 결과물: src/main_trn03.py, README.md, docs/trn03_final_report.pdf(md) 형태의 결과물을 완성한다.

🔍 평가 및 검증 기준 (PASS 조건)

미션 판정

평가 구분: 필수

핵심 평가영역: 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과

미션 PASS 조건

  • 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상

  • 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료

  • 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증

채점자 판정 메모

단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.

In [ ]:
"""합성 실루엣 생성·검증부터 전처리, 분류(AAR 재보정값 적용), 신뢰도 등급, 학습 퀴즈 운영, 오답 해설 교관 검토 대기열까지 학습앱 전체를 한 파일로 통합 실행하고, 자체 점검·함수 커버리지·부대 교육 배포 브리핑 요약을 출력한다."""
import hashlib
import json
import sys
import time
import types
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler

# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
       "cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}


def _rect(x0, x1, y0, y1):
    # [TODO : 학생 실습 10] 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
def _bar(p, q, w):
    # [TODO : 학생 실습 10] 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성 관련 핵심 로직을 구현하세요.
    # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
    # <<< 여기에 코드를 작성하세요 >>>
    pass  # 작성 후 pass를 제거하세요.
SHAPES = {
    "civil_quad": {"poly": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],
                   "circ": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},
    "civil_fw": {"poly": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], "circ": []},
    "attack_fw": {"poly": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], "circ": []},
    "cruise": {"poly": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),
                        [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], "circ": []},
    "ballistic": {"poly": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],
                           [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], "circ": []},
    "bird": {"poly": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),
                       (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], "circ": []},
}
_S = 2
_g = (np.arange(IMG * _S) + 0.5) / _S
_GX, _GY = np.meshgrid(_g, _g)
_PTS = np.c_[_GX.ravel(), _GY.ravel()]
_PATHS = {c: [MplPath(np.array(p)) for p in s["poly"]] for c, s in SHAPES.items()}


def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,
         clutter=0.0, occlude=0.0, invert=False, shift=1.5):
    """클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자."""
    angle = rng.uniform(0, 360) if angle is None else angle
    scale = rng.uniform(.7, .95) if scale is None else scale
    fore = rng.uniform(.85, 1.0) if fore is None else fore
    th = np.radians(angle)
    c0 = IMG / 2 + rng.uniform(-shift, shift, 2)
    d = (_PTS - c0) / (scale * IMG / 2)                          # 이미지 좌표 -> 도형 좌표(역변환)
    u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)
    v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore      # 원근 축소(기수 방향으로 짧아 보임)
    uv = np.c_[u, -v]
    m = np.zeros(len(uv), bool)
    near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0)          # 도형이 있을 수 있는 영역만 검사(속도)
    for p in _PATHS[cls]:
        m[near] |= p.contains_points(uv[near])
    for cx, cy, r in SHAPES[cls]["circ"]:
        m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r
    cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))
    if occlude > 0:                                               # 부분 가림(구름 조각)
        h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)
        cov[y0:y0 + h, x0:x0 + h] = 0
    bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]
    fg = rng.uniform(30, 60)
    img = bg * (1 - cov) + fg * cov
    if clutter > 0:                                               # 구름·배경 얼룩
        img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)
    if blur > 0:
        img = ndimage.gaussian_filter(img, blur)
    mean = img.mean()
    img = mean + (img - mean) * contrast
    img = img + rng.normal(0, noise, img.shape)
    if invert:                                                    # 야간 열상(IR) 극성 반전
        img = 255 - img
    return np.clip(img, 0, 255).astype(np.uint8)


# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오
CONDITIONS = {
    "basic": {},
    "offcenter": {"shift": 5.0, "fore": (.6, .85)},
    "far": {"scale": (.32, .45), "blur": .6},
    "blur": {"blur": (1.2, 1.8)},
    "lowcon": {"contrast": (.2, .35)},
    "clutter": {"clutter": .8},
    "occlude": {"occlude": .08},
    "oblique": {"fore": (.35, .6)},
    "night": {"invert": True, "noise": 10.0},
    "noise": {"noise": 28.0},
    "combo": {"scale": (.4, .55), "blur": (.8, 1.1), "noise": 12.0, "contrast": (.6, .8)},
}


def draw_cond(cls, cond, rng):
    kw = {}
    for k, v in CONDITIONS[cond].items():
        kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v
    return draw(cls, rng, **kw)


def make_dataset(n_per_class, seed, conds=("basic",)):
    """(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다."""
    rng = np.random.default_rng(seed)
    X, y, c = [], [], []
    for cls in CLASSES:
        for i in range(n_per_class):
            cond = conds[i % len(conds)]
            X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)
    return np.array(X), np.array(y), np.array(c)
# ---------------------------------------------------------------------------

# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----
OUT = 24                                  # 크기 정규화 결과(24x24)
NOISE_T = 12.0                            # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)
FEATS = ["elong", "fill", "solidity", "n_parts", "end_gap", "mid_bulge", "w_max", "compact"]


def preprocess(images, noise_t=NOISE_T):
    """N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화."""
    f = np.array(images, dtype=float).reshape(-1, IMG, IMG)
    n = len(f)
    bad = ~np.isfinite(f)
    missing = bad.sum(axis=(1, 2))
    if bad.any():                                         # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정
        med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])
        f = np.where(bad, med[:, None, None], f)
        f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)
    border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)
    inverted = np.median(border, axis=1) < 128            # 배경이 어두우면 야간 열상으로 보고 반전
    f[inverted] = 255 - f[inverted]
    med3 = ndimage.median_filter(f, size=(1, 3, 3))
    noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)
    noisy = noise > noise_t
    f[noisy] = med3[noisy]
    lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)
    e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1)   # 명암 대비 강화
    mask = e < _otsu(e)[:, None, None]
    mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5          # 고립 점 잡음 제거
    edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2))                    # 윤곽선(Sobel)
    ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)
    return {"enh": e, "mask": mask, "edge": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],
            "norm": _normalize(1 - e, mask, ok), "inverted": inverted, "noise": noise, "denoised": noisy,
            "missing": missing, "contrast": hi - lo, "ok": ok}


def _otsu(e, bins=64):
    n = len(e)
    idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]
    hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)
    centers = (np.arange(bins) + 0.5) / bins
    w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)
    tot, mt = w[:, -1:], m[:, -1:]
    between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)
    return (np.argmax(between, axis=1) + 1) / bins


def _normalize(obj, mask, ok):
    """주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리."""
    n = len(obj)
    w = mask.astype(float)
    m00 = np.maximum(w.sum(axis=(1, 2)), 1)
    yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)
    cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00
    dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]
    mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))
    th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)
    a = np.stack([np.cos(th), np.sin(th)], 1)             # 주축(y, x)
    b = np.stack([-np.sin(th), np.cos(th)], 1)            # 부축
    pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)
    pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)
    half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0
    g = np.linspace(-1, 1, OUT)
    U, V = np.meshgrid(g, g, indexing="ij")
    Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])
    X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])
    N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)
    out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)
    skew = (out * (U ** 3)).sum(axis=(1, 2))
    out[skew < 0] = out[skew < 0, ::-1]                    # 무게가 쏠린 쪽을 위로(180도 모호성 제거)
    out[~ok] = 0
    return np.clip(out, 0, 1)


def shape_features(mask):
    """분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다."""
    if mask.sum() < 4:
        return dict.fromkeys(FEATS, 0.0)
    yx = np.argwhere(mask).astype(float)
    c = yx - yx.mean(axis=0)
    val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))
    t, s = c @ vec[:, 1], c @ vec[:, 0]
    length, width = np.ptp(t) + 1, np.ptp(s) + 1
    corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])
    try:
        hull = ConvexHull(corners).volume
    except Exception:
        hull = float(mask.sum())
    bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])
    w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length
    boundary = mask & ~ndimage.binary_erosion(mask)
    return {"elong": float(np.sqrt(val[1] / max(val[0], 1e-6))), "fill": float(mask.sum() / (length * width)),
            "solidity": float(mask.sum() / max(hull, 1)), "n_parts": float(ndimage.label(mask)[1]),
            "end_gap": float(abs(w[0] - w[2])), "mid_bulge": float(w[1] - max(w[0], w[2])),
            "w_max": float(w.max()), "compact": float(boundary.sum() / np.sqrt(mask.sum()))}


def feature_matrix(images):
    """전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다."""
    P = preprocess(images)
    F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P["mask"])])
    return P, F, P["norm"].reshape(len(F), -1)

CONFIG = {"augment_conditions": ["combo", "oblique", "occlude", "clutter", "far"], "extra_classes": ["cruise", "ballistic", "civil_fw", "attack_fw"],
          "min_confidence": 0.5, "hidden_layer": 96}                     # 미션 9 재보정값
CALLED = set()


def _tracer(frame, event, arg):
    if event == "call" and frame.f_globals is globals():
        CALLED.add(frame.f_code.co_name)


def validate(img):
    a = np.asarray(img, dtype=float) if img is not None else None
    return a is not None and a.shape == (IMG, IMG) and np.isfinite(a).all() and a.min() >= 0 and a.max() <= 255


def train():
    X1, y1, _ = make_dataset(80, 5, tuple(CONDITIONS))
    X2, y2, _ = make_dataset(40, 6, tuple(CONFIG["augment_conditions"]))
    rng = np.random.default_rng(7)
    ex = CONFIG["extra_classes"] * 20
    X3 = np.array([draw_cond(c, CONFIG["augment_conditions"][i % 5], rng) for i, c in enumerate(ex)])
    X, y = np.r_[X1, X2, X3], np.r_[y1, y2, ex]
    P, F, N = feature_matrix(X)
    sc = StandardScaler().fit(F)
    m = MLPClassifier((CONFIG["hidden_layer"],), max_iter=200, early_stopping=True, random_state=0)
    return m.fit(np.c_[N, sc.transform(F) * 0.3], [CLASSES.index(v) for v in y]), sc


def classify(m, sc, images):
    P, F, N = feature_matrix(images)
    prob = m.predict_proba(np.c_[N, sc.transform(F) * 0.3])
    pred = np.array(CLASSES)[prob.argmax(1)]
    conf = prob.max(1) * np.where(P["ok"], 1, 0)
    return pred, conf, np.where(conf >= 0.8, "High", np.where(conf >= CONFIG["min_confidence"], "Medium", "Low"))


class Queue:
    def __init__(self):
        self.items, self.log = [], []

    def submit(self, rec):
        prev = self.log[-1]["hash"] if self.log else "GENESIS"
        body = {"event": "SUBMIT", **rec}
        self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})
        self.items.append({**rec, "status": "PENDING"})


sys.settrace(_tracer)
t_start = time.perf_counter()
# 1. 학습
model, scaler = train()
# 2. 운용 데이터(교육장 배포 문항 300장) 검증 -> 손상 3장 차단
X, y, cond = make_dataset(50, 41, tuple(CONDITIONS))
items = list(X)
items[5], items[77], items[150] = None, np.zeros((20, 20)), np.full((IMG, IMG), np.nan)
ok_idx = [i for i, im in enumerate(items) if validate(im)]
rejected = len(items) - len(ok_idx)
Xo, yo = np.array([items[i] for i in ok_idx]), y[ok_idx]
t0 = time.perf_counter()
pred, conf, grade = classify(model, scaler, Xo)
ms = (time.perf_counter() - t0) * 1000 / len(Xo)
judged = grade != "Low"
acc_all = (pred == yo).mean()
acc_judged = (pred[judged] == yo[judged]).mean()
# 3. 퀴즈 운영(가상 장병 20명 x 10문항) -> 오답은 해설 검토 대기열(자동 공개 없음)
rng = np.random.default_rng(9)
q, answers = Queue(), []
CONFUSE = {"civil_fw": "attack_fw", "attack_fw": "civil_fw", "cruise": "ballistic", "ballistic": "cruise", "bird": "civil_fw", "civil_quad": "civil_fw"}
for s in range(20):
    for k in rng.choice(len(yo), 10, replace=False):
        ans = yo[k] if rng.random() < 0.75 else CONFUSE[yo[k]]
        answers.append(ans == yo[k])
        if ans != yo[k]:
            q.submit({"learner": f"L{s + 1:02d}", "answer": str(yo[k]), "chosen": str(ans), "ai_grade": str(grade[k])})
sys.settrace(None)
total = time.perf_counter() - t_start

defined = {n for n, f in globals().items() if isinstance(f, types.FunctionType) and f.__module__ == __name__ and not n.startswith("_tracer")}
defined |= {"submit", "__init__"}
coverage = len(CALLED & defined) / len(defined)
chk = {
    "손상 데이터 3건 차단": rejected == 3,
    "판별 대상 297장 처리": len(Xo) == 297,
    "전체 분류 정확도 85% 이상": acc_all >= 0.85,
    "판별(Medium 이상) 정확도 95% 이상": acc_judged >= 0.95,
    "High 등급 정확도 >= Medium 등급 정확도": (pred == yo)[grade == "High"].mean() >= (pred == yo)[grade == "Medium"].mean(),
    "장당 처리 200 ms 이내": ms <= 200,
    "오답 해설 자동 공개 0건": all(i["status"] == "PENDING" for i in q.items),
    "감사 로그 건수 일치": len(q.log) == len(q.items) == sum(not a for a in answers),
    "함수 커버리지 85% 이상": coverage >= 0.85,
    "전체 실행 60초 이내": total < 60,
}
for k, v in chk.items():
    print(f"[{'PASS' if v else 'FAIL'}] {k}")
passed = sum(chk.values())
print(f"자체 점검 {passed}/{len(chk)} 통과, 함수 커버리지 {coverage:.0%} ({len(CALLED & defined)}/{len(defined)}), 통합 실행 {'성공' if passed == len(chk) else '점검 필요'}")

fig, ax = plt.subplots(2, 2, figsize=(11, 8))
for j in range(6):
    sub = ax[0, 0].inset_axes([j % 3 / 3, 1 - (j // 3 + 1) / 2, 0.32, 0.48])
    i = np.flatnonzero(yo == CLASSES[j])[0]
    sub.imshow(Xo[i], cmap="gray"); sub.set_title(f"{CLASSES[j]}\n{pred[i]} ({grade[i]})", fontsize=7); sub.axis("off")
ax[0, 0].axis("off"); ax[0, 0].set_title("Sample items: true / predicted (grade)")
g = pd.Series(grade).value_counts().reindex(["High", "Medium", "Low"]).fillna(0)
ax[0, 1].bar(g.index, g.values, color=["tab:green", "tab:orange", "tab:red"]); ax[0, 1].set_title("Confidence grade of deployed items")
acc_c = pd.Series(pred == yo).groupby(cond[ok_idx]).mean()
ax[1, 0].bar(acc_c.index, acc_c.values); ax[1, 0].tick_params(axis="x", rotation=40); ax[1, 0].set_title("AI accuracy by condition")
ax[1, 1].axis("off")
ax[1, 1].text(0, .9, f"Items        {len(Xo)} (+{rejected} rejected)\nAI accuracy  {acc_all:.1%}\nLearner acc  {np.mean(answers):.1%}\n"
              f"Review queue {len(q.items)} pending\nCoverage     {coverage:.0%}\nSelf-check   {passed}/{len(chk)}", family="monospace", fontsize=12, va="top")
plt.suptitle("TRN03 silhouette ID app - integrated run (training data only)"); plt.tight_layout(); plt.show()

top = pd.Series([f"{i['answer']}->{i['chosen']}" for i in q.items]).value_counts().head(2)
print(f"""
[부대 교육 배포 브리핑 요약 - 교육용가상자료]
1. 구성: 합성 추상 실루엣 6종(위협 3종), 문항 {len(items)}장 중 {len(Xo)}장 배포, 손상 {rejected}장 자동 차단
2. AI 성능: 전체 정확도 {acc_all:.1%}, 신뢰도 Medium 이상 판별 정확도 {acc_judged:.1%}, Low {int((~judged).sum())}장은 교관 확인, 장당 {ms:.2f} ms
3. 학습 운영: 가상 장병 20명 정답률 {np.mean(answers):.1%}, 다빈도 오답 {', '.join(f'{k}({v})' for k, v in top.items())}
4. 교관 검토: 오답 해설 {len(q.items)}건 검토 대기, 자동 공개 0건, 감사 로그 {len(q.log)}건
5. 운용 유의: 실루엣은 기본 도형으로 만든 학습용 추상 이미지이며 실제 기종 식별 교범을 대체하지 않는다. 실제 표적 판단은 정식 교육과 지휘계통에 따른다.
6. 확장 로드맵: 학습 기록 내보내기 API(문항 ID·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스""")
💡 [참고] 미션 10 예상 실행 결과 (Output)
[PASS] 손상 데이터 3건 차단
[PASS] 판별 대상 297장 처리
[PASS] 전체 분류 정확도 85% 이상
[PASS] 판별(Medium 이상) 정확도 95% 이상
[PASS] High 등급 정확도 >= Medium 등급 정확도
[PASS] 장당 처리 200 ms 이내
[PASS] 오답 해설 자동 공개 0건
[PASS] 감사 로그 건수 일치
[PASS] 함수 커버리지 85% 이상
[PASS] 전체 실행 60초 이내
자체 점검 10/10 통과, 함수 커버리지 87% (13/15), 통합 실행 성공

[부대 교육 배포 브리핑 요약 - 교육용가상자료]
1. 구성: 합성 추상 실루엣 6종(위협 3종), 문항 300장 중 297장 배포, 손상 3장 자동 차단
2. AI 성능: 전체 정확도 85.2%, 신뢰도 Medium 이상 판별 정확도 96.7%, Low 55장은 교관 확인, 장당 1.47 ms
3. 학습 운영: 가상 장병 20명 정답률 75.0%, 다빈도 오답 ballistic->cruise(12), civil_quad->civil_fw(11)
4. 교관 검토: 오답 해설 50건 검토 대기, 자동 공개 0건, 감사 로그 50건
5. 운용 유의: 실루엣은 기본 도형으로 만든 학습용 추상 이미지이며 실제 기종 식별 교범을 대체하지 않는다. 실제 표적 판단은 정식 교육과 지휘계통에 따른다.
6. 확장 로드맵: 학습 기록 내보내기 API(문항 ID·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스