# TRN03 정답 코드 - 드론·미사일 실루엣 이미지 분류 학습앱 > [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다. ## 실행 환경 - Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib - 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다. - 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다. - 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다. ## 미션 1. 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축 - 난이도: 초급 / 예상 소요시간: 1.5시간 - 코드 설명: 전술 드론·순항/탄도미사일 등 비행체 실루엣 이미지(흑백·원거리·다각도) 학습 데이터셋의 레이블 체계와 메타데이터 스키마를 정의하고, 합성 실루엣 샘플을 만들어 검증한다. ```python """전술 드론·순항/탄도미사일 등 비행체 실루엣 이미지(흑백·원거리·다각도) 학습 데이터셋의 레이블 체계와 메타데이터 스키마를 정의하고, 합성 실루엣 샘플을 만들어 검증한다.""" 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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text 정상 샘플 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 ``` ## 미션 2. 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현 - 난이도: 초급~중급 / 예상 소요시간: 2.0시간 - 코드 설명: 실루엣 이미지의 결측 보정, 야간 극성 보정, 잡음 제거, 명암 대비 강화, 윤곽선(Edge) 검출, 방향·크기 정규화를 일괄 처리하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·전처리 전후 식별 정확도를 확인한다. ```python """실루엣 이미지의 결측 보정, 야간 극성 보정, 잡음 제거, 명암 대비 강화, 윤곽선(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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text 전처리 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 ``` ## 미션 3. 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현 - 난이도: 중급 / 예상 소요시간: 2.5시간 - 코드 설명: 형태 특징 8종(길쭉함·채움률·볼록도·폭 분포 등)으로 비행체 실루엣을 1차 분류하는 결정나무 Baseline을 구현하고, 판단 규칙을 그대로 출력해 투명성을 확인하며 기본 데이터셋 정확도·처리 지연·조건별 한계를 측정한다. ```python """형태 특징 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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text 규칙 깊이 검증 정확도 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건 ``` ## 미션 4. 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화 - 난이도: 중급 / 예상 소요시간: 3.0시간 - 코드 설명: 다양한 각도·원거리·흐림·잡음·구름·가림·야간 조건을 섞어 학습한 다층 퍼셉트론(소형 신경망) 분류기로 실루엣 분류를 고도화하고, 복합 위협 시나리오 10종에서 위협 재현율·처리 지연·오경보 유발 패턴을 검증한다. ```python """다양한 각도·원거리·흐림·잡음·구름·가림·야간 조건을 섞어 학습한 다층 퍼셉트론(소형 신경망) 분류기로 실루엣 분류를 고도화하고, 복합 위협 시나리오 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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text 신경망 학습 완료 (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) 외에 윤곽선 곡률 특징 추가를 검토한다. ``` ## 미션 5. 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현 - 난이도: 중급~고급 / 예상 소요시간: 3.0시간 - 코드 설명: 민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 줄이기 위해, 분류 확률·1/2위 확률 차이·영상 품질을 결합한 신뢰도 점수와 등급(High/Medium/Low)을 산출하고, 오경보율(FAR) 감축·점수 일관성·ROC-AUC·임계값 민감도를 검증한다. ```python """민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 줄이기 위해, 분류 확률·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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text 방식 위협 경보 오경보 오경보율 위협 재현율 기존(최고 확률 클래스로 단정) 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 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다. ``` ## 미션 6. 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현 - 난이도: 중급 / 예상 소요시간: 2.5시간 - 코드 설명: 장병이 실루엣을 보고 비행체를 고르는 대화형 식별 학습 앱 UI 프로토타입을 구현한다. 문제 출제·응답 처리·즉시 피드백·AI 참고 판정·진행률 표시를 모의 실행해 화면 갱신 주기와 응답 처리시간을 측정하고, 야간 모드 학습 화면을 그린다. ```python """장병이 실루엣을 보고 비행체를 고르는 대화형 식별 학습 앱 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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text [문항 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 ``` ## 미션 7. 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계 - 난이도: 고급 / 예상 소요시간: 3.0시간 - 코드 설명: 학습자가 오답을 내면 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징(날개 형상 등) 해설 초안을 만들고, 교관이 승인·수정·반려해야만 학습자에게 공개되는 인간 검토(HITL) 해설 모듈을 구현한다. 모든 처리는 위·변조 확인이 가능한 감사 로그에 남는다. ```python """학습자가 오답을 내면 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징(날개 형상 등) 해설 초안을 만들고, 교관이 승인·수정·반려해야만 학습자에게 공개되는 인간 검토(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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text 오답 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, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견 ``` ## 미션 8. 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증 - 난이도: 고급 / 예상 소요시간: 3.5시간 - 코드 설명: 흐리고 품질이 낮은 실루엣 이미지를 평시의 최대 6배(500% 증가)까지 한꺼번에 넣고 손상 입력을 섞어, 분류 서비스가 멈추지 않는지(다운 0건)·결과가 빠지지 않는지(유실률 0%)·흐림 한계치·처리량·메모리·병목 구간을 검증한다. ```python """흐리고 품질이 낮은 실루엣 이미지를 평시의 최대 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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text 상황 요청 응답 유실률 다운 판별불가 입력오류 판별 정확도 처리량(장/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% -> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다 ``` ## 미션 9. AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습 - 난이도: 고급 / 예상 소요시간: 3.0시간 - 코드 설명: AAR(사후검토) 형식으로 장병 학습 기록의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 원인별로 분석하고, 취약 조건·클래스를 증강한 데이터로 재학습해 종합 정확도 향상·재발 오류·회귀 여부와 재보정 파라미터를 산출한다. ```python """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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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() ``` 실행 결과 예시 ```text [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 재보정값 적용), 신뢰도 등급, 학습 퀴즈 운영, 오답 해설 교관 검토 대기열까지 학습앱 전체를 한 파일로 통합 실행하고, 자체 점검·함수 커버리지·부대 교육 배포 브리핑 요약을 출력한다. ```python """합성 실루엣 생성·검증부터 전처리, 분류(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): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] def _bar(p, q, w): (x0, y0), (x1, y1) = p, q n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2 return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])] # 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다. 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·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스""") ``` 실행 결과 예시 ```text [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·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스 ```