261 KiB
261 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""전술 드론·순항/탄도미사일 등 비행체 실루엣 이미지(흑백·원거리·다각도) 학습 데이터셋의 레이블 체계와 메타데이터 스키마를 정의하고, 합성 실루엣 샘플을 만들어 검증한다."""
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""실루엣 이미지의 결측 보정, 야간 극성 보정, 잡음 제거, 명암 대비 강화, 윤곽선(Edge) 검출, 방향·크기 정규화를 일괄 처리하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·전처리 전후 식별 정확도를 확인한다."""
import time
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neighbors import NearestCentroid
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""형태 특징 8종(길쭉함·채움률·볼록도·폭 분포 등)으로 비행체 실루엣을 1차 분류하는 결정나무 Baseline을 구현하고, 판단 규칙을 그대로 출력해 투명성을 확인하며 기본 데이터셋 정확도·처리 지연·조건별 한계를 측정한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""다양한 각도·원거리·흐림·잡음·구름·가림·야간 조건을 섞어 학습한 다층 퍼셉트론(소형 신경망) 분류기로 실루엣 분류를 고도화하고, 복합 위협 시나리오 10종에서 위협 재현율·처리 지연·오경보 유발 패턴을 검증한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 줄이기 위해, 분류 확률·1/2위 확률 차이·영상 품질을 결합한 신뢰도 점수와 등급(High/Medium/Low)을 산출하고, 오경보율(FAR) 감축·점수 일관성·ROC-AUC·임계값 민감도를 검증한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.metrics import roc_auc_score, roc_curve
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""장병이 실루엣을 보고 비행체를 고르는 대화형 식별 학습 앱 UI 프로토타입을 구현한다. 문제 출제·응답 처리·즉시 피드백·AI 참고 판정·진행률 표시를 모의 실행해 화면 갱신 주기와 응답 처리시간을 측정하고, 야간 모드 학습 화면을 그린다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""학습자가 오답을 내면 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징(날개 형상 등) 해설 초안을 만들고, 교관이 승인·수정·반려해야만 학습자에게 공개되는 인간 검토(HITL) 해설 모듈을 구현한다. 모든 처리는 위·변조 확인이 가능한 감사 로그에 남는다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""흐리고 품질이 낮은 실루엣 이미지를 평시의 최대 6배(500% 증가)까지 한꺼번에 넣고 손상 입력을 섞어, 분류 서비스가 멈추지 않는지(다운 0건)·결과가 빠지지 않는지(유실률 0%)·흐림 한계치·처리량·메모리·병목 구간을 검증한다."""
import time
import tracemalloc
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""AAR(사후검토) 형식으로 장병 학습 기록의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 원인별로 분석하고, 취약 조건·클래스를 증강한 데이터로 재학습해 종합 정확도 향상·재발 오류·회귀 여부와 재보정 파라미터를 산출한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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()
In [ ]:
"""합성 실루엣 생성·검증부터 전처리, 분류(AAR 재보정값 적용), 신뢰도 등급, 학습 퀴즈 운영, 오답 해설 교관 검토 대기열까지 학습앱 전체를 한 파일로 통합 실행하고, 자체 점검·함수 커버리지·부대 교육 배포 브리핑 요약을 출력한다."""
import hashlib
import json
import sys
import time
import types
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from scipy import ndimage
from scipy.spatial import ConvexHull
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)
# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.
# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.
# ---------------------------------------------------------------------------
IMG = 32
CLASSES = ["civil_quad", "civil_fw", "attack_fw", "cruise", "ballistic", "bird"]
KOR = {"civil_quad": "민간 쿼드콥터형", "civil_fw": "민간 고정익형", "attack_fw": "삼각익 자폭드론형",
"cruise": "순항미사일형", "ballistic": "탄도미사일형", "bird": "조류"}
THREAT = {"attack_fw", "cruise", "ballistic"}
def _rect(x0, x1, y0, y1):
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·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스""")