2493 lines
120 KiB
Markdown
2493 lines
120 KiB
Markdown
# OPS01 정답 코드 - 드론 영상 공중위협 객체 탐지와 오경보 검증
|
|
|
|
> [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다.
|
|
|
|
## 실행 환경
|
|
|
|
- Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib
|
|
- 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다.
|
|
- 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다.
|
|
- 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다.
|
|
|
|
## 미션 1. 전술 드론 영상 수집 환경 분석 및 공중위협 객체(드론/미사일) 데이터셋 스키마 정의
|
|
|
|
- 난이도: 초급 / 예상 소요시간: 1.5시간
|
|
- 코드 설명: 드론 탑재 EO/IR 영상의 공중위협 객체(정찰·자폭드론, 순항미사일) 메타데이터·라벨 스키마를 정의하고, 가상 샘플 데이터를 검증한다.
|
|
|
|
```python
|
|
"""드론 탑재 EO/IR 영상의 공중위협 객체(정찰·자폭드론, 순항미사일) 메타데이터·라벨 스키마를 정의하고, 가상 샘플 데이터를 검증한다."""
|
|
import json
|
|
import re
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
# 1) 스키마 정의: 센서 규격, 전술 파라미터(기상·위치오차·통신지연), 바운딩 박스 라벨
|
|
SCHEMA = {
|
|
"required": ["sequence_id", "sensor", "tactical", "n_frames", "labels"],
|
|
"fields": {
|
|
"sequence_id": {"type": str, "pattern": r"^SEQ\d{3}$"},
|
|
"n_frames": {"type": int, "min": 1, "max": 3000},
|
|
},
|
|
"sensor": {
|
|
"type": {"type": str, "enum": ["EO", "IR"]},
|
|
"resolution": {"type": list, "length": 2},
|
|
"fps": {"type": (int, float), "min": 1, "max": 60},
|
|
},
|
|
"tactical": {
|
|
"weather": {"type": str, "enum": ["clear", "night", "fog", "rain", "smoke"]},
|
|
"position_error_m": {"type": (int, float), "min": 0, "max": 500},
|
|
"comm_delay_ms": {"type": (int, float), "min": 0, "max": 10000},
|
|
},
|
|
"label": {
|
|
"class": {"type": str, "enum": ["recon_drone", "attack_drone", "cruise_missile", "bird", "civil_aircraft", "clutter"]},
|
|
"bbox": {"type": list, "length": 4},
|
|
"frame": {"type": int, "min": 0},
|
|
"thermal": {"type": (int, float), "min": 0, "max": 255},
|
|
},
|
|
}
|
|
|
|
|
|
def check(name, value, rule):
|
|
"""값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다."""
|
|
if not isinstance(value, rule["type"]) or isinstance(value, 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 = [f"{k}: 필수 필드 누락" for k in SCHEMA["required"] if k not in rec]
|
|
for k, rule in SCHEMA["fields"].items():
|
|
if k in rec:
|
|
errs += check(k, rec[k], rule)
|
|
for group in ("sensor", "tactical"):
|
|
for k, rule in SCHEMA[group].items():
|
|
if k not in rec.get(group, {}):
|
|
errs.append(f"{group}.{k}: 필수 필드 누락")
|
|
else:
|
|
errs += check(f"{group}.{k}", rec[group][k], rule)
|
|
for i, lab in enumerate(rec.get("labels", [])):
|
|
for k, rule in SCHEMA["label"].items():
|
|
if k not in lab:
|
|
errs.append(f"labels[{i}].{k}: 필수 필드 누락")
|
|
else:
|
|
errs += check(f"labels[{i}].{k}", lab[k], rule)
|
|
return errs
|
|
|
|
|
|
# 2) [교육용가상자료] 샘플 메타데이터 20건 생성
|
|
rng = np.random.default_rng(1)
|
|
classes = SCHEMA["label"]["class"]["enum"]
|
|
records = []
|
|
for i in range(20):
|
|
labels = [{"class": str(rng.choice(classes)), "frame": int(f),
|
|
"bbox": [int(rng.integers(0, 60)), int(rng.integers(0, 60)), 3, 3],
|
|
"thermal": float(round(rng.uniform(100, 240), 1))} for f in range(3)]
|
|
records.append({"sequence_id": f"SEQ{i:03d}", "n_frames": 30,
|
|
"sensor": {"type": str(rng.choice(["EO", "IR"])), "resolution": [64, 64], "fps": 10},
|
|
"tactical": {"weather": str(rng.choice(["clear", "night", "fog", "rain", "smoke"])),
|
|
"position_error_m": float(round(rng.uniform(2, 30), 1)),
|
|
"comm_delay_ms": int(rng.integers(20, 400))},
|
|
"labels": labels})
|
|
|
|
# 3) 정상 데이터 검증
|
|
ok = sum(1 for r in records if not validate(r))
|
|
print(f"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건")
|
|
|
|
# 4) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인
|
|
bad = json.loads(json.dumps(records[0]))
|
|
bad["sequence_id"] = "SEQ-1"
|
|
bad["sensor"]["fps"] = 120
|
|
del bad["tactical"]["comm_delay_ms"]
|
|
bad["labels"][0]["class"] = "ufo"
|
|
bad["labels"][1]["bbox"] = [1, 2, 3]
|
|
print("\n[오류 주입 결과]")
|
|
for e in validate(bad):
|
|
print(" -", e)
|
|
|
|
# 5) 스키마 필드 정의서(명세서 초안)
|
|
rows = [("기본", k, r["type"].__name__, str({x: y for x, y in r.items() if x != "type"})) for k, r in SCHEMA["fields"].items()]
|
|
for g in ("sensor", "tactical", "label"):
|
|
for k, r in SCHEMA[g].items():
|
|
t = r["type"].__name__ if isinstance(r["type"], type) else "number"
|
|
rows.append((g, k, t, str({x: y for x, y in r.items() if x != "type"})))
|
|
spec = pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙"])
|
|
print("\n[데이터 명세 요약]")
|
|
print(spec.to_string(index=False))
|
|
print("\n클래스별 라벨 수:", pd.Series([l["class"] for r in records for l in r["labels"]]).value_counts().to_dict())
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
정상 샘플 20건 중 검증 통과 20건, 오류 0건
|
|
|
|
[오류 주입 결과]
|
|
- sequence_id: 형식 불일치 'SEQ-1'
|
|
- sensor.fps: 최대값 60 초과
|
|
- tactical.comm_delay_ms: 필수 필드 누락
|
|
- labels[0].class: 허용되지 않은 값 'ufo'
|
|
- labels[1].bbox: 길이 4 필요
|
|
|
|
[데이터 명세 요약]
|
|
그룹 필드 자료형 규칙
|
|
기본 sequence_id str {'pattern': '^SEQ\\d{3}$'}
|
|
기본 n_frames int {'min': 1, 'max': 3000}
|
|
sensor type str {'enum': ['EO', 'IR']}
|
|
sensor resolution list {'length': 2}
|
|
sensor fps number {'min': 1, 'max': 60}
|
|
tactical weather str {'enum': ['clear', 'night', 'fog', 'rain', 'smoke']}
|
|
tactical position_error_m number {'min': 0, 'max': 500}
|
|
tactical comm_delay_ms number {'min': 0, 'max': 10000}
|
|
label class str {'enum': ['recon_drone', 'attack_drone', 'cruise_missile', 'bird', 'civil_aircraft', 'clutter']}
|
|
label bbox list {'length': 4}
|
|
label frame int {'min': 0}
|
|
label thermal number {'min': 0, 'max': 255}
|
|
|
|
클래스별 라벨 수: {'bird': 12, 'recon_drone': 12, 'cruise_missile': 10, 'clutter': 10, 'attack_drone': 8, 'civil_aircraft': 8}
|
|
```
|
|
|
|
## 미션 2. 적외선·광학(EO/IR) 전장 영상 노이즈 제거 및 기상 왜곡 보정 전처리 모듈 구현
|
|
|
|
- 난이도: 초급~중급 / 예상 소요시간: 2.0시간
|
|
- 코드 설명: 안개·강우·야간 등 기상 왜곡과 센서 잡음이 섞인 EO/IR 영상을 조건별로 전처리(잡음 제거·빗줄기 제거·명암비 강화·정규화)하고, 처리 속도와 탐지 품질 개선 효과를 확인한다.
|
|
|
|
```python
|
|
"""안개·강우·야간 등 기상 왜곡과 센서 잡음이 섞인 EO/IR 영상을 조건별로 전처리(잡음 제거·빗줄기 제거·명암비 강화·정규화)하고, 처리 속도와 탐지 품질 개선 효과를 확인한다."""
|
|
import time
|
|
import numpy as np
|
|
import matplotlib.pyplot as plt
|
|
from scipy import ndimage
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)
|
|
# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.
|
|
# ---------------------------------------------------------------------------
|
|
H = W = 64
|
|
PROFILE = {"drone": ((2, 3), (170, 225)), "bird": ((2, 3), (110, 150)),
|
|
"civil": ((4, 6), (190, 240)), "clutter": ((2, 4), (140, 190))}
|
|
|
|
|
|
def _new_obj(cls, rng, dive=False, v=None, xy=None):
|
|
(s0, s1), (h0, h1) = PROFILE[cls]
|
|
x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))
|
|
speed = {"drone": 1.3, "bird": 0.9, "civil": 0.35, "clutter": 0.0}[cls]
|
|
vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)
|
|
return {"cls": cls, "size": int(rng.integers(s0, s1 + 1)), "heat": float(rng.uniform(h0, h1)),
|
|
"x": float(x), "y": float(y), "vx": float(vx), "vy": float(vy), "dive": dive}
|
|
|
|
|
|
def _noise(img, cond, rng):
|
|
f = img.astype(float)
|
|
if cond == "night":
|
|
f = f * 0.8 + rng.normal(0, 14, f.shape)
|
|
elif cond == "fog":
|
|
f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)
|
|
elif cond == "rain":
|
|
f = f + rng.normal(0, 8, f.shape)
|
|
for _ in range(20):
|
|
x, y0 = rng.integers(0, W), rng.integers(0, H - 8)
|
|
f[y0:y0 + 8, x] += 45
|
|
else:
|
|
f = f + rng.normal(0, 6, f.shape)
|
|
return np.clip(f, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def make_sequence(seq_id, cond, rng, kind="mixed", n_frames=30, n_drone=None, n_bird=None):
|
|
"""시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation"""
|
|
objs = []
|
|
if kind == "swarm":
|
|
v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)
|
|
objs = [_new_obj("drone", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]
|
|
objs.append(_new_obj("bird", rng))
|
|
elif kind == "dive":
|
|
d = _new_obj("drone", rng, dive=True)
|
|
d["y"] = float(rng.uniform(4, 14))
|
|
objs = [d, _new_obj("bird", rng), _new_obj("clutter", rng)]
|
|
elif kind == "saturation":
|
|
objs = [_new_obj("drone", rng) for _ in range(n_drone)] + [_new_obj("bird", rng) for _ in range(n_bird)]
|
|
else:
|
|
objs = [_new_obj("drone", rng) for _ in range(int(rng.integers(1, 3)))]
|
|
objs += [_new_obj("bird", rng) for _ in range(int(rng.integers(0, 3)))]
|
|
if rng.random() < 0.5:
|
|
objs.append(_new_obj("civil", rng))
|
|
if rng.random() < 0.6:
|
|
objs.append(_new_obj("clutter", rng))
|
|
frames, labels = np.zeros((n_frames, H, W), np.uint8), []
|
|
base = rng.normal(40, 6, (H, W))
|
|
for t in range(n_frames):
|
|
canvas = base.copy()
|
|
for i, o in enumerate(objs):
|
|
if t > 0:
|
|
if o["cls"] == "bird":
|
|
o["vx"] = float(np.clip(o["vx"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
o["vy"] = float(np.clip(o["vy"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
if o["dive"] and t > 10:
|
|
o["vy"] += 0.18
|
|
o["x"] = float(np.clip(o["x"] + o["vx"], 1, W - o["size"] - 1))
|
|
o["y"] = float(np.clip(o["y"] + o["vy"], 1, H - o["size"] - 1))
|
|
heat = o["heat"]
|
|
if o["cls"] == "clutter" and rng.random() < 0.3:
|
|
heat *= 0.7
|
|
if o["cls"] == "bird":
|
|
heat *= float(rng.uniform(0.85, 1.1))
|
|
x, y, s = int(round(o["x"])), int(round(o["y"])), o["size"]
|
|
canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)
|
|
labels.append({"frame": t, "track": f"{seq_id}_T{i:02d}", "class": o["cls"], "bbox": [x, y, s, s],
|
|
"pattern": "dive" if o["dive"] else ("swarm" if kind == "swarm" and o["cls"] == "drone" else "normal")})
|
|
frames[t] = _noise(canvas, cond, rng)
|
|
return frames, labels
|
|
|
|
|
|
def make_dataset(n=24, seed=7):
|
|
rng = np.random.default_rng(seed)
|
|
conds = ["clear", "night", "fog", "rain"]
|
|
out = []
|
|
for i in range(n):
|
|
kind = "swarm" if i % 8 == 5 else ("dive" if i % 8 == 6 else "mixed")
|
|
f, lab = make_sequence(f"SEQ{i:03d}", conds[i % 4], rng, kind)
|
|
out.append({"id": f"SEQ{i:03d}", "cond": conds[i % 4], "kind": kind, "frames": f, "labels": lab})
|
|
return out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def denoise(f, size):
|
|
return f if size <= 1 else ndimage.median_filter(f, size=size)
|
|
|
|
|
|
def remove_rain(f):
|
|
return ndimage.grey_opening(f, size=(1, 2))
|
|
|
|
|
|
def clahe_like(f, tiles=4, clip=0.02):
|
|
"""타일별 히스토그램 평활화 후 전체 대비 확장(간이 CLAHE). 안개로 떨어진 국소 대비를 살린다."""
|
|
out = np.zeros_like(f, dtype=float)
|
|
th, tw = f.shape[0] // tiles, f.shape[1] // tiles
|
|
for i in range(tiles):
|
|
for j in range(tiles):
|
|
t = f[i * th:(i + 1) * th, j * tw:(j + 1) * tw].astype(float)
|
|
hist, edges = np.histogram(t, bins=64, range=(0, 256))
|
|
limit = max(1, int(clip * t.size))
|
|
hist = np.minimum(hist, limit)
|
|
cdf = np.cumsum(hist) / hist.sum()
|
|
out[i * th:(i + 1) * th, j * tw:(j + 1) * tw] = np.interp(t, edges[:-1], cdf) * 255
|
|
return 0.5 * out + 0.5 * stretch(f)
|
|
|
|
|
|
def stretch(f):
|
|
lo, hi = np.percentile(f, [1, 99.5])
|
|
return np.clip((f.astype(float) - lo) / (hi - lo + 1e-6) * 255, 0, 255)
|
|
|
|
|
|
def normalize(f):
|
|
return f.astype(np.float32) / 255.0
|
|
|
|
|
|
PARAMS = {"clear": dict(median=1, rain=False), "night": dict(median=1, rain=False),
|
|
"fog": dict(median=1, rain=False), "rain": dict(median=1, rain=True)}
|
|
|
|
|
|
def preprocess(frame, cond):
|
|
p = PARAMS[cond]
|
|
f = denoise(frame.astype(float), p["median"])
|
|
if p["rain"]:
|
|
f = remove_rain(f)
|
|
return stretch(f)
|
|
|
|
|
|
def quick_f1(seqs, use_pre, k=2.5):
|
|
tp = fp = fn = 0
|
|
for s in seqs:
|
|
for t, fr in enumerate(s["frames"]):
|
|
img = preprocess(fr, s["cond"]) if use_pre else fr.astype(float)
|
|
lab, n = ndimage.label(img > img.mean() + k * img.std())
|
|
sizes = ndimage.sum(np.ones_like(img), lab, range(1, n + 1))
|
|
n_det = int(((sizes >= 3) & (sizes <= 60)).sum())
|
|
n_gt = sum(1 for l in s["labels"] if l["frame"] == t)
|
|
tp += min(n_det, n_gt); fp += max(0, n_det - n_gt); fn += max(0, n_gt - n_det)
|
|
p, r = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
|
|
return 2 * p * r / max(p + r, 1e-9)
|
|
|
|
|
|
data = make_dataset(24, seed=7)
|
|
|
|
# 1) 처리 속도 측정 (프레임/초)
|
|
frames = [(fr, s["cond"]) for s in data for fr in s["frames"]]
|
|
t0 = time.perf_counter()
|
|
for fr, c in frames:
|
|
normalize(preprocess(fr, c))
|
|
dt = time.perf_counter() - t0
|
|
print(f"전처리 {len(frames)}프레임, {len(frames) / dt:,.0f} 프레임/초, 오류 0건")
|
|
|
|
# 2) 조건별 전처리 전후 탐지 F1 비교
|
|
print("\n조건 전처리 전 F1 전처리 후 F1")
|
|
for c in ["clear", "night", "fog", "rain"]:
|
|
sub = [s for s in data if s["cond"] == c]
|
|
print(f"{c:6s} {quick_f1(sub, False):10.3f} {quick_f1(sub, True):10.3f}")
|
|
|
|
# 3) 필터를 과하게 쓰면 소형 표적이 사라지는지 확인 (3x3 중간값 필터)
|
|
PARAMS["night"]["median"] = 3
|
|
night = [s for s in data if s["cond"] == "night"]
|
|
print(f"\n야간 3x3 중간값 필터 적용 시 F1: {quick_f1(night, True):.3f} (2px 소형 드론이 함께 지워짐)")
|
|
PARAMS["night"]["median"] = 1
|
|
|
|
# 4) 결측 프레임 보정(앞 프레임으로 채우기)
|
|
seq = list(data[3]["frames"]); seq[5] = None; seq[6] = None
|
|
filled = list(seq)
|
|
for i in range(len(filled)):
|
|
if filled[i] is None:
|
|
filled[i] = filled[i - 1]
|
|
print("결측 프레임 2장 보정 완료:", all(f is not None for f in filled))
|
|
|
|
# 5) 전후 비교 그림
|
|
fig, ax = plt.subplots(2, 4, figsize=(12, 6))
|
|
for j, c in enumerate(["clear", "night", "fog", "rain"]):
|
|
s = next(x for x in data if x["cond"] == c)
|
|
ax[0, j].imshow(s["frames"][0], cmap="gray"); ax[0, j].set_title(f"{c} raw")
|
|
ax[1, j].imshow(clahe_like(s["frames"][0]) if c == "fog" else preprocess(s["frames"][0], c), cmap="gray")
|
|
ax[1, j].set_title(f"{c} preprocessed")
|
|
for a in ax.ravel():
|
|
a.axis("off")
|
|
plt.suptitle("Synthetic IR frames: before / after preprocessing (training data)")
|
|
plt.tight_layout()
|
|
plt.show()
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
전처리 720프레임, 3,422 프레임/초, 오류 0건
|
|
|
|
조건 전처리 전 F1 전처리 후 F1
|
|
clear 0.956 0.958
|
|
night 0.986 0.988
|
|
fog 0.989 0.991
|
|
rain 0.500 0.838
|
|
|
|
야간 3x3 중간값 필터 적용 시 F1: 0.857 (2px 소형 드론이 함께 지워짐)
|
|
결측 프레임 2장 보정 완료: True
|
|
```
|
|
|
|
## 미션 3. YOLO/규칙 기반 저고도 공중위협 객체 탐지 Baseline 프로토타입 구현
|
|
|
|
- 난이도: 중급 / 예상 소요시간: 2.5시간
|
|
- 코드 설명: 규칙 기반(밝기 임계값 + 연결 영역 분석) 저고도 공중위협 객체 탐지 Baseline을 구현하고, IoU 매칭으로 탐지 정확도(정밀도·재현율)를 측정한다. 경량 YOLO를 쓸 경우와의 차이도 함께 비교한다.
|
|
|
|
```python
|
|
"""규칙 기반(밝기 임계값 + 연결 영역 분석) 저고도 공중위협 객체 탐지 Baseline을 구현하고, IoU 매칭으로 탐지 정확도(정밀도·재현율)를 측정한다. 경량 YOLO를 쓸 경우와의 차이도 함께 비교한다."""
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.patches import Rectangle
|
|
from scipy import ndimage
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)
|
|
# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.
|
|
# ---------------------------------------------------------------------------
|
|
H = W = 64
|
|
PROFILE = {"drone": ((2, 3), (170, 225)), "bird": ((2, 3), (110, 150)),
|
|
"civil": ((4, 6), (190, 240)), "clutter": ((2, 4), (140, 190))}
|
|
|
|
|
|
def _new_obj(cls, rng, dive=False, v=None, xy=None):
|
|
(s0, s1), (h0, h1) = PROFILE[cls]
|
|
x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))
|
|
speed = {"drone": 1.3, "bird": 0.9, "civil": 0.35, "clutter": 0.0}[cls]
|
|
vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)
|
|
return {"cls": cls, "size": int(rng.integers(s0, s1 + 1)), "heat": float(rng.uniform(h0, h1)),
|
|
"x": float(x), "y": float(y), "vx": float(vx), "vy": float(vy), "dive": dive}
|
|
|
|
|
|
def _noise(img, cond, rng):
|
|
f = img.astype(float)
|
|
if cond == "night":
|
|
f = f * 0.8 + rng.normal(0, 14, f.shape)
|
|
elif cond == "fog":
|
|
f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)
|
|
elif cond == "rain":
|
|
f = f + rng.normal(0, 8, f.shape)
|
|
for _ in range(20):
|
|
x, y0 = rng.integers(0, W), rng.integers(0, H - 8)
|
|
f[y0:y0 + 8, x] += 45
|
|
else:
|
|
f = f + rng.normal(0, 6, f.shape)
|
|
return np.clip(f, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def make_sequence(seq_id, cond, rng, kind="mixed", n_frames=30, n_drone=None, n_bird=None):
|
|
"""시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation"""
|
|
objs = []
|
|
if kind == "swarm":
|
|
v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)
|
|
objs = [_new_obj("drone", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]
|
|
objs.append(_new_obj("bird", rng))
|
|
elif kind == "dive":
|
|
d = _new_obj("drone", rng, dive=True)
|
|
d["y"] = float(rng.uniform(4, 14))
|
|
objs = [d, _new_obj("bird", rng), _new_obj("clutter", rng)]
|
|
elif kind == "saturation":
|
|
objs = [_new_obj("drone", rng) for _ in range(n_drone)] + [_new_obj("bird", rng) for _ in range(n_bird)]
|
|
else:
|
|
objs = [_new_obj("drone", rng) for _ in range(int(rng.integers(1, 3)))]
|
|
objs += [_new_obj("bird", rng) for _ in range(int(rng.integers(0, 3)))]
|
|
if rng.random() < 0.5:
|
|
objs.append(_new_obj("civil", rng))
|
|
if rng.random() < 0.6:
|
|
objs.append(_new_obj("clutter", rng))
|
|
frames, labels = np.zeros((n_frames, H, W), np.uint8), []
|
|
base = rng.normal(40, 6, (H, W))
|
|
for t in range(n_frames):
|
|
canvas = base.copy()
|
|
for i, o in enumerate(objs):
|
|
if t > 0:
|
|
if o["cls"] == "bird":
|
|
o["vx"] = float(np.clip(o["vx"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
o["vy"] = float(np.clip(o["vy"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
if o["dive"] and t > 10:
|
|
o["vy"] += 0.18
|
|
o["x"] = float(np.clip(o["x"] + o["vx"], 1, W - o["size"] - 1))
|
|
o["y"] = float(np.clip(o["y"] + o["vy"], 1, H - o["size"] - 1))
|
|
heat = o["heat"]
|
|
if o["cls"] == "clutter" and rng.random() < 0.3:
|
|
heat *= 0.7
|
|
if o["cls"] == "bird":
|
|
heat *= float(rng.uniform(0.85, 1.1))
|
|
x, y, s = int(round(o["x"])), int(round(o["y"])), o["size"]
|
|
canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)
|
|
labels.append({"frame": t, "track": f"{seq_id}_T{i:02d}", "class": o["cls"], "bbox": [x, y, s, s],
|
|
"pattern": "dive" if o["dive"] else ("swarm" if kind == "swarm" and o["cls"] == "drone" else "normal")})
|
|
frames[t] = _noise(canvas, cond, rng)
|
|
return frames, labels
|
|
|
|
|
|
def make_dataset(n=24, seed=7):
|
|
rng = np.random.default_rng(seed)
|
|
conds = ["clear", "night", "fog", "rain"]
|
|
out = []
|
|
for i in range(n):
|
|
kind = "swarm" if i % 8 == 5 else ("dive" if i % 8 == 6 else "mixed")
|
|
f, lab = make_sequence(f"SEQ{i:03d}", conds[i % 4], rng, kind)
|
|
out.append({"id": f"SEQ{i:03d}", "cond": conds[i % 4], "kind": kind, "frames": f, "labels": lab})
|
|
return out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---- 전처리·탐지 공통 함수 ----
|
|
def preprocess(frame, cond):
|
|
f = frame.astype(float)
|
|
if cond == "rain": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)
|
|
f = ndimage.grey_opening(f, size=(1, 2))
|
|
lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장
|
|
return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)
|
|
|
|
|
|
def detect(frame, cond, k=2.0, min_area=3, max_area=60):
|
|
img = preprocess(frame, cond)
|
|
lab, _ = ndimage.label(img > img.mean() + k * img.std())
|
|
dets = []
|
|
for i, sl in enumerate(ndimage.find_objects(lab), start=1):
|
|
area = int((lab[sl] == i).sum())
|
|
if min_area <= area <= max_area:
|
|
y, x = sl[0].start, sl[1].start
|
|
dets.append({"bbox": [x, y, sl[1].stop - x, sl[0].stop - y], "area": area,
|
|
"thermal": float(frame[sl][lab[sl] == i].max())})
|
|
return dets
|
|
|
|
|
|
def iou(a, b):
|
|
ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
|
|
iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
|
|
inter = ix * iy
|
|
return inter / (a[2] * a[3] + b[2] * b[3] - inter)
|
|
|
|
|
|
def evaluate(data, k=2.0, classes=("drone", "bird", "civil", "clutter")):
|
|
tp = fp = fn = 0
|
|
for s in data:
|
|
for t, fr in enumerate(s["frames"]):
|
|
gts = [l["bbox"] for l in s["labels"] if l["frame"] == t and l["class"] in classes]
|
|
used = set()
|
|
dets = detect(fr, s["cond"], k)
|
|
for d in dets:
|
|
best = max(((iou(d["bbox"], g), j) for j, g in enumerate(gts) if j not in used), default=(0, -1))
|
|
if best[0] >= 0.1:
|
|
tp += 1; used.add(best[1])
|
|
fp += len(dets) - len(used); fn += len(gts) - len(used)
|
|
p, r = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
|
|
return {"TP": tp, "FP": fp, "FN": fn, "정밀도": round(p, 3), "재현율": round(r, 3), "F1": round(2 * p * r / max(p + r, 1e-9), 3)}
|
|
|
|
|
|
data = make_dataset(24, seed=7)
|
|
|
|
# 1) 기상 조건별 객체 탐지 성능 (드론·조류·민간기·클러터 모두 '객체'로 봄)
|
|
rows = []
|
|
for c in ["clear", "night", "fog", "rain"]:
|
|
rows.append({"조건": c, **evaluate([s for s in data if s["cond"] == c])})
|
|
rows.append({"조건": "전체", **evaluate(data)})
|
|
print(pd.DataFrame(rows).to_string(index=False))
|
|
acc = rows[-1]["재현율"]
|
|
print(f"\n목표(탐지 정확도 75% 이상) 달성 여부: {'달성' if acc >= 0.75 else '미달'} (재현율 {acc:.1%})")
|
|
|
|
# 2) 드론만 기준으로 보면? -> 조류·민간기까지 잡혀 정밀도가 크게 떨어진다 (미션 4·5의 동기)
|
|
d = evaluate(data, classes=("drone",))
|
|
print(f"드론만 정답으로 볼 때 정밀도 {d['정밀도']:.1%}, 재현율 {d['재현율']:.1%}")
|
|
|
|
# 3) 임계값 k에 따른 민감도 변화
|
|
print("\nk 정밀도 재현율")
|
|
for k in [1.5, 2.0, 2.5, 3.0, 3.5]:
|
|
e = evaluate(data, k)
|
|
print(f"{k:<4} {e['정밀도']:.3f} {e['재현율']:.3f}")
|
|
|
|
# 4) 규칙 기반 vs 경량 YOLO 비교표
|
|
print(pd.DataFrame({"항목": ["학습 데이터", "연산 자원", "소형 표적(2~3px)", "판단 근거 설명", "도입 속도"],
|
|
"규칙 기반(본 Baseline)": ["불필요", "CPU로 충분", "밝기 차이에 의존", "쉬움(임계값)", "즉시"],
|
|
"경량 YOLO": ["라벨 수천 장 이상", "GPU 권장", "학습 데이터 품질에 의존", "어려움", "학습 후"]}).to_string(index=False))
|
|
|
|
# 5) 탐지 결과 시각화
|
|
s = data[5]
|
|
fig, ax = plt.subplots(1, 3, figsize=(12, 4))
|
|
for a, t in zip(ax, [0, 10, 20]):
|
|
a.imshow(s["frames"][t], cmap="gray")
|
|
for det in detect(s["frames"][t], s["cond"]):
|
|
x, y, w, h = det["bbox"]
|
|
a.add_patch(Rectangle((x - .5, y - .5), w, h, fill=False, color="red"))
|
|
a.set_title(f"{s['id']} frame {t} (red = detection)")
|
|
a.axis("off")
|
|
plt.tight_layout()
|
|
plt.show()
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
조건 TP FP FN 정밀도 재현율 F1
|
|
clear 767 0 43 1.000 0.947 0.973
|
|
night 799 10 11 0.988 0.986 0.987
|
|
fog 561 28 9 0.952 0.984 0.968
|
|
rain 591 391 9 0.602 0.985 0.747
|
|
전체 2718 429 72 0.864 0.974 0.916
|
|
|
|
목표(탐지 정확도 75% 이상) 달성 여부: 달성 (재현율 97.4%)
|
|
드론만 정답으로 볼 때 정밀도 39.8%, 재현율 99.4%
|
|
|
|
k 정밀도 재현율
|
|
1.5 0.569 0.976
|
|
2.0 0.864 0.974
|
|
2.5 0.920 0.963
|
|
3.0 0.933 0.878
|
|
3.5 0.951 0.764
|
|
항목 규칙 기반(본 Baseline) 경량 YOLO
|
|
학습 데이터 불필요 라벨 수천 장 이상
|
|
연산 자원 CPU로 충분 GPU 권장
|
|
소형 표적(2~3px) 밝기 차이에 의존 학습 데이터 품질에 의존
|
|
판단 근거 설명 쉬움(임계값) 어려움
|
|
도입 속도 즉시 학습 후
|
|
```
|
|
|
|
## 미션 4. 군집 드론 및 비행체 기만 비행 패턴 식별 복합 위협 탐지 모델 고도화
|
|
|
|
- 난이도: 중급 / 예상 소요시간: 3.0시간
|
|
- 코드 설명: 프레임별 탐지를 궤적으로 연결(추적)하고, 궤적의 움직임 특징으로 군집 드론(동일 속도 동행)·급강하 자폭 궤적·불규칙(조류형 기만) 비행 패턴을 식별해 복합 위협 탐지 재현율과 처리 지연을 측정한다.
|
|
|
|
```python
|
|
"""프레임별 탐지를 궤적으로 연결(추적)하고, 궤적의 움직임 특징으로 군집 드론(동일 속도 동행)·급강하 자폭 궤적·불규칙(조류형 기만) 비행 패턴을 식별해 복합 위협 탐지 재현율과 처리 지연을 측정한다."""
|
|
import time
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
from scipy import ndimage
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)
|
|
# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.
|
|
# ---------------------------------------------------------------------------
|
|
H = W = 64
|
|
PROFILE = {"drone": ((2, 3), (170, 225)), "bird": ((2, 3), (110, 150)),
|
|
"civil": ((4, 6), (190, 240)), "clutter": ((2, 4), (140, 190))}
|
|
|
|
|
|
def _new_obj(cls, rng, dive=False, v=None, xy=None):
|
|
(s0, s1), (h0, h1) = PROFILE[cls]
|
|
x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))
|
|
speed = {"drone": 1.3, "bird": 0.9, "civil": 0.35, "clutter": 0.0}[cls]
|
|
vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)
|
|
return {"cls": cls, "size": int(rng.integers(s0, s1 + 1)), "heat": float(rng.uniform(h0, h1)),
|
|
"x": float(x), "y": float(y), "vx": float(vx), "vy": float(vy), "dive": dive}
|
|
|
|
|
|
def _noise(img, cond, rng):
|
|
f = img.astype(float)
|
|
if cond == "night":
|
|
f = f * 0.8 + rng.normal(0, 14, f.shape)
|
|
elif cond == "fog":
|
|
f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)
|
|
elif cond == "rain":
|
|
f = f + rng.normal(0, 8, f.shape)
|
|
for _ in range(20):
|
|
x, y0 = rng.integers(0, W), rng.integers(0, H - 8)
|
|
f[y0:y0 + 8, x] += 45
|
|
else:
|
|
f = f + rng.normal(0, 6, f.shape)
|
|
return np.clip(f, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def make_sequence(seq_id, cond, rng, kind="mixed", n_frames=30, n_drone=None, n_bird=None):
|
|
"""시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation"""
|
|
objs = []
|
|
if kind == "swarm":
|
|
v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)
|
|
objs = [_new_obj("drone", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]
|
|
objs.append(_new_obj("bird", rng))
|
|
elif kind == "dive":
|
|
d = _new_obj("drone", rng, dive=True)
|
|
d["y"] = float(rng.uniform(4, 14))
|
|
objs = [d, _new_obj("bird", rng), _new_obj("clutter", rng)]
|
|
elif kind == "saturation":
|
|
objs = [_new_obj("drone", rng) for _ in range(n_drone)] + [_new_obj("bird", rng) for _ in range(n_bird)]
|
|
else:
|
|
objs = [_new_obj("drone", rng) for _ in range(int(rng.integers(1, 3)))]
|
|
objs += [_new_obj("bird", rng) for _ in range(int(rng.integers(0, 3)))]
|
|
if rng.random() < 0.5:
|
|
objs.append(_new_obj("civil", rng))
|
|
if rng.random() < 0.6:
|
|
objs.append(_new_obj("clutter", rng))
|
|
frames, labels = np.zeros((n_frames, H, W), np.uint8), []
|
|
base = rng.normal(40, 6, (H, W))
|
|
for t in range(n_frames):
|
|
canvas = base.copy()
|
|
for i, o in enumerate(objs):
|
|
if t > 0:
|
|
if o["cls"] == "bird":
|
|
o["vx"] = float(np.clip(o["vx"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
o["vy"] = float(np.clip(o["vy"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
if o["dive"] and t > 10:
|
|
o["vy"] += 0.18
|
|
o["x"] = float(np.clip(o["x"] + o["vx"], 1, W - o["size"] - 1))
|
|
o["y"] = float(np.clip(o["y"] + o["vy"], 1, H - o["size"] - 1))
|
|
heat = o["heat"]
|
|
if o["cls"] == "clutter" and rng.random() < 0.3:
|
|
heat *= 0.7
|
|
if o["cls"] == "bird":
|
|
heat *= float(rng.uniform(0.85, 1.1))
|
|
x, y, s = int(round(o["x"])), int(round(o["y"])), o["size"]
|
|
canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)
|
|
labels.append({"frame": t, "track": f"{seq_id}_T{i:02d}", "class": o["cls"], "bbox": [x, y, s, s],
|
|
"pattern": "dive" if o["dive"] else ("swarm" if kind == "swarm" and o["cls"] == "drone" else "normal")})
|
|
frames[t] = _noise(canvas, cond, rng)
|
|
return frames, labels
|
|
|
|
|
|
def make_dataset(n=24, seed=7):
|
|
rng = np.random.default_rng(seed)
|
|
conds = ["clear", "night", "fog", "rain"]
|
|
out = []
|
|
for i in range(n):
|
|
kind = "swarm" if i % 8 == 5 else ("dive" if i % 8 == 6 else "mixed")
|
|
f, lab = make_sequence(f"SEQ{i:03d}", conds[i % 4], rng, kind)
|
|
out.append({"id": f"SEQ{i:03d}", "cond": conds[i % 4], "kind": kind, "frames": f, "labels": lab})
|
|
return out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---- 전처리·탐지 공통 함수 ----
|
|
def preprocess(frame, cond):
|
|
f = frame.astype(float)
|
|
if cond == "rain": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)
|
|
f = ndimage.grey_opening(f, size=(1, 2))
|
|
lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장
|
|
return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)
|
|
|
|
|
|
def detect(frame, cond, k=2.0, min_area=3, max_area=60):
|
|
img = preprocess(frame, cond)
|
|
lab, _ = ndimage.label(img > img.mean() + k * img.std())
|
|
dets = []
|
|
for i, sl in enumerate(ndimage.find_objects(lab), start=1):
|
|
area = int((lab[sl] == i).sum())
|
|
if min_area <= area <= max_area:
|
|
y, x = sl[0].start, sl[1].start
|
|
dets.append({"bbox": [x, y, sl[1].stop - x, sl[0].stop - y], "area": area,
|
|
"thermal": float(frame[sl][lab[sl] == i].max())})
|
|
return dets
|
|
|
|
|
|
def iou(a, b):
|
|
ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
|
|
iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
|
|
inter = ix * iy
|
|
return inter / (a[2] * a[3] + b[2] * b[3] - inter)
|
|
|
|
# ---- 추적·특징·패턴 공통 함수 ----
|
|
def center(b):
|
|
return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])
|
|
|
|
|
|
def track(per_frame, gate=6.0, max_missed=2, min_len=5):
|
|
"""예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다."""
|
|
active, done = [], []
|
|
for t, dets in enumerate(per_frame):
|
|
used = set()
|
|
for tr in sorted(active, key=lambda x: -len(x["t"])):
|
|
last = center(tr["b"][-1])
|
|
vel = last - center(tr["b"][-2]) if len(tr["b"]) > 1 else np.zeros(2)
|
|
pred = last + vel * (t - tr["t"][-1])
|
|
cand = [(np.linalg.norm(center(d["bbox"]) - pred), j) for j, d in enumerate(dets) if j not in used]
|
|
dist, j = min(cand, default=(gate, -1))
|
|
if j >= 0 and dist < gate:
|
|
used.add(j)
|
|
tr["t"].append(t); tr["b"].append(dets[j]["bbox"])
|
|
tr["area"].append(dets[j]["area"]); tr["heat"].append(dets[j]["thermal"])
|
|
still = []
|
|
for tr in active:
|
|
(still if t - tr["t"][-1] <= max_missed else done).append(tr)
|
|
active = still
|
|
for j, d in enumerate(dets):
|
|
if j not in used:
|
|
active.append({"t": [t], "b": [d["bbox"]], "area": [d["area"]], "heat": [d["thermal"]]})
|
|
return [tr for tr in done + active if len(tr["t"]) >= min_len]
|
|
|
|
|
|
FEATURES = ["mean_speed", "speed_std", "turn", "straight", "vy_accel", "area", "heat", "heat_std"]
|
|
|
|
|
|
def features(tr):
|
|
c = np.array([center(b) for b in tr["b"]]); f = np.array(tr["t"], float)
|
|
v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]
|
|
sp = np.linalg.norm(v, axis=1)
|
|
k = 3 if len(c) > 4 else 1
|
|
v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]
|
|
hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2
|
|
turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]
|
|
half = len(v) // 2
|
|
return {"mean_speed": sp.mean(), "speed_std": sp.std(), "turn": turns.mean() if len(turns) else 0.0,
|
|
"straight": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),
|
|
"vy_accel": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,
|
|
"area": np.mean(tr["area"]), "heat": np.mean(tr["heat"]), "heat_std": np.std(tr["heat"]),
|
|
"vx": v[:, 0].mean(), "vy": v[:, 1].mean(), "x": c[-1, 0], "y": c[-1, 1]}
|
|
|
|
|
|
def pattern(f):
|
|
if f["mean_speed"] < 0.25:
|
|
return "static"
|
|
if f["vy_accel"] > 0.06:
|
|
return "dive"
|
|
if f["turn"] > 0.4 or f["straight"] < 0.6:
|
|
return "erratic"
|
|
return "steady"
|
|
|
|
|
|
def swarms(trs, vel=0.2, dist=18.0, min_n=3):
|
|
"""속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다."""
|
|
cand = [i for i, t in enumerate(trs) if t["pattern"] != "static"]
|
|
adj = {i: set() for i in cand}
|
|
for a in cand:
|
|
for b in cand:
|
|
fa, fb = trs[a]["f"], trs[b]["f"]
|
|
if a < b and np.hypot(fa["vx"] - fb["vx"], fa["vy"] - fb["vy"]) < vel and np.hypot(fa["x"] - fb["x"], fa["y"] - fb["y"]) < dist:
|
|
adj[a].add(b); adj[b].add(a)
|
|
seen, groups = set(), []
|
|
for i in cand:
|
|
if i in seen:
|
|
continue
|
|
stack, comp = [i], []
|
|
while stack:
|
|
k = stack.pop()
|
|
if k not in seen:
|
|
seen.add(k); comp.append(k); stack += list(adj[k] - seen)
|
|
if len(comp) >= min_n:
|
|
groups.append(comp)
|
|
return groups
|
|
|
|
|
|
def analyze(s, k=2.0):
|
|
"""시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용)."""
|
|
per = [detect(fr, s["cond"], k) if fr is not None else [] for fr in s["frames"]]
|
|
trs = track(per)
|
|
for tr in trs:
|
|
tr["f"] = features(tr); tr["pattern"] = pattern(tr["f"]); tr["swarm"] = None; tr["seq"] = s["id"]
|
|
votes = {}
|
|
for t, b in zip(tr["t"], tr["b"]):
|
|
best = max(((iou(b, l["bbox"]), l) for l in s["labels"] if l["frame"] == t), default=(0, None), key=lambda x: x[0])
|
|
key = (best[1]["track"], best[1]["class"], best[1]["pattern"]) if best[0] > 0.05 else ("none", "noise", "normal")
|
|
votes[key] = votes.get(key, 0) + 1
|
|
tr["gt_track"], tr["gt_class"], tr["gt_pattern"] = max(votes, key=votes.get)
|
|
for g, comp in enumerate(swarms(trs)):
|
|
for i in comp:
|
|
trs[i]["swarm"] = g
|
|
if trs[i]["pattern"] in ("steady", "erratic"):
|
|
trs[i]["pattern"] = "swarm"
|
|
return trs
|
|
|
|
|
|
data = make_dataset(24, seed=7)
|
|
t0 = time.perf_counter()
|
|
tracks = [tr for s in data for tr in analyze(s)]
|
|
latency = (time.perf_counter() - t0) / sum(len(s["frames"]) for s in data) * 1000
|
|
|
|
# 1) 드론 궤적 재현율: 정답 드론 중 '움직이는 위협 후보'로 잡힌 비율
|
|
gt_drones = {l["track"] for s in data for l in s["labels"] if l["class"] == "drone"}
|
|
found = {t["gt_track"] for t in tracks if t["gt_class"] == "drone" and t["pattern"] != "static"}
|
|
recall = len(found) / len(gt_drones)
|
|
print(f"드론 궤적 재현율 {recall:.1%} (목표 85% 이상: {'달성' if recall >= 0.85 else '미달'}), 프레임당 처리 {latency:.2f} ms (목표 200 ms 이하)")
|
|
|
|
# 2) 실제 정체 x 판정 패턴 교차표
|
|
print("\n[실제 정체별 판정 패턴]")
|
|
print(pd.crosstab(pd.Series([t["gt_class"] for t in tracks], name="실제"), pd.Series([t["pattern"] for t in tracks], name="판정")))
|
|
|
|
# 3) 급강하·군집 시나리오 식별
|
|
dives = [t for t in tracks if t["gt_pattern"] == "dive"]
|
|
print(f"\n급강하 드론 {len(dives)}개 중 dive 판정 {sum(t['pattern'] == 'dive' for t in dives)}개")
|
|
for sid in sorted({t["seq"] for t in tracks if t["gt_pattern"] == "swarm"}):
|
|
n = sum(1 for t in tracks if t["seq"] == sid and t["swarm"] is not None and t["gt_class"] == "drone")
|
|
print(f"군집 시나리오 {sid}: 군집으로 묶인 드론 궤적 {n}개")
|
|
print("\n참고: 화면 가장자리에 닿아 속도가 왜곡되면 군집이 끊길 수 있다. 조류처럼 흔들리며 나는 기만 비행은 움직임만으로 구분이 어려워 미션 5에서 열신호 등과 함께 판단한다.")
|
|
|
|
# 4) 궤적 시각화
|
|
s = next(x for x in data if x["kind"] == "swarm")
|
|
trs = analyze(s)
|
|
color = {"steady": "tab:blue", "dive": "tab:red", "erratic": "tab:gray", "swarm": "tab:purple", "static": "black"}
|
|
plt.figure(figsize=(6, 6))
|
|
plt.imshow(s["frames"][-1], cmap="gray")
|
|
for t in trs:
|
|
c = np.array([center(b) for b in t["b"]])
|
|
plt.plot(c[:, 0], c[:, 1], "-", color=color[t["pattern"]], lw=2, label=t["pattern"])
|
|
h, l = plt.gca().get_legend_handles_labels()
|
|
plt.legend(dict(zip(l, h)).values(), dict(zip(l, h)).keys())
|
|
plt.title(f"{s['id']} tracks by pattern (synthetic)")
|
|
plt.show()
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
드론 궤적 재현율 100.0% (목표 85% 이상: 달성), 프레임당 처리 1.02 ms (목표 200 ms 이하)
|
|
|
|
[실제 정체별 판정 패턴]
|
|
판정 dive erratic static steady swarm
|
|
실제
|
|
bird 5 12 0 12 0
|
|
civil 0 5 0 4 0
|
|
clutter 0 4 11 0 0
|
|
drone 3 4 0 25 12
|
|
|
|
급강하 드론 3개 중 dive 판정 3개
|
|
군집 시나리오 SEQ005: 군집으로 묶인 드론 궤적 4개
|
|
군집 시나리오 SEQ013: 군집으로 묶인 드론 궤적 4개
|
|
군집 시나리오 SEQ021: 군집으로 묶인 드론 궤적 4개
|
|
|
|
참고: 화면 가장자리에 닿아 속도가 왜곡되면 군집이 끊길 수 있다. 조류처럼 흔들리며 나는 기만 비행은 움직임만으로 구분이 어려워 미션 5에서 열신호 등과 함께 판단한다.
|
|
```
|
|
|
|
## 미션 5. 조류·민간기 등 오표적 식별 방지 및 신뢰도 점수(Confidence Score) 산출 엔진 구현
|
|
|
|
- 난이도: 중급~고급 / 예상 소요시간: 3.0시간
|
|
- 코드 설명: 조류·민간기·지상 반사체 등 오표적을 걸러내기 위해 궤적의 크기·속도·열신호 특징으로 '드론일 확률'(Confidence Score)과 등급(High/Medium/Low)을 계산하고, 기존 방식 대비 오경보율(FAR) 감축과 점수 일관성을 검증한다.
|
|
|
|
```python
|
|
"""조류·민간기·지상 반사체 등 오표적을 걸러내기 위해 궤적의 크기·속도·열신호 특징으로 '드론일 확률'(Confidence Score)과 등급(High/Medium/Low)을 계산하고, 기존 방식 대비 오경보율(FAR) 감축과 점수 일관성을 검증한다."""
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
from scipy import ndimage
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.pipeline import make_pipeline
|
|
from sklearn.preprocessing import StandardScaler
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)
|
|
# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.
|
|
# ---------------------------------------------------------------------------
|
|
H = W = 64
|
|
PROFILE = {"drone": ((2, 3), (170, 225)), "bird": ((2, 3), (110, 150)),
|
|
"civil": ((4, 6), (190, 240)), "clutter": ((2, 4), (140, 190))}
|
|
|
|
|
|
def _new_obj(cls, rng, dive=False, v=None, xy=None):
|
|
(s0, s1), (h0, h1) = PROFILE[cls]
|
|
x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))
|
|
speed = {"drone": 1.3, "bird": 0.9, "civil": 0.35, "clutter": 0.0}[cls]
|
|
vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)
|
|
return {"cls": cls, "size": int(rng.integers(s0, s1 + 1)), "heat": float(rng.uniform(h0, h1)),
|
|
"x": float(x), "y": float(y), "vx": float(vx), "vy": float(vy), "dive": dive}
|
|
|
|
|
|
def _noise(img, cond, rng):
|
|
f = img.astype(float)
|
|
if cond == "night":
|
|
f = f * 0.8 + rng.normal(0, 14, f.shape)
|
|
elif cond == "fog":
|
|
f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)
|
|
elif cond == "rain":
|
|
f = f + rng.normal(0, 8, f.shape)
|
|
for _ in range(20):
|
|
x, y0 = rng.integers(0, W), rng.integers(0, H - 8)
|
|
f[y0:y0 + 8, x] += 45
|
|
else:
|
|
f = f + rng.normal(0, 6, f.shape)
|
|
return np.clip(f, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def make_sequence(seq_id, cond, rng, kind="mixed", n_frames=30, n_drone=None, n_bird=None):
|
|
"""시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation"""
|
|
objs = []
|
|
if kind == "swarm":
|
|
v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)
|
|
objs = [_new_obj("drone", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]
|
|
objs.append(_new_obj("bird", rng))
|
|
elif kind == "dive":
|
|
d = _new_obj("drone", rng, dive=True)
|
|
d["y"] = float(rng.uniform(4, 14))
|
|
objs = [d, _new_obj("bird", rng), _new_obj("clutter", rng)]
|
|
elif kind == "saturation":
|
|
objs = [_new_obj("drone", rng) for _ in range(n_drone)] + [_new_obj("bird", rng) for _ in range(n_bird)]
|
|
else:
|
|
objs = [_new_obj("drone", rng) for _ in range(int(rng.integers(1, 3)))]
|
|
objs += [_new_obj("bird", rng) for _ in range(int(rng.integers(0, 3)))]
|
|
if rng.random() < 0.5:
|
|
objs.append(_new_obj("civil", rng))
|
|
if rng.random() < 0.6:
|
|
objs.append(_new_obj("clutter", rng))
|
|
frames, labels = np.zeros((n_frames, H, W), np.uint8), []
|
|
base = rng.normal(40, 6, (H, W))
|
|
for t in range(n_frames):
|
|
canvas = base.copy()
|
|
for i, o in enumerate(objs):
|
|
if t > 0:
|
|
if o["cls"] == "bird":
|
|
o["vx"] = float(np.clip(o["vx"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
o["vy"] = float(np.clip(o["vy"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
if o["dive"] and t > 10:
|
|
o["vy"] += 0.18
|
|
o["x"] = float(np.clip(o["x"] + o["vx"], 1, W - o["size"] - 1))
|
|
o["y"] = float(np.clip(o["y"] + o["vy"], 1, H - o["size"] - 1))
|
|
heat = o["heat"]
|
|
if o["cls"] == "clutter" and rng.random() < 0.3:
|
|
heat *= 0.7
|
|
if o["cls"] == "bird":
|
|
heat *= float(rng.uniform(0.85, 1.1))
|
|
x, y, s = int(round(o["x"])), int(round(o["y"])), o["size"]
|
|
canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)
|
|
labels.append({"frame": t, "track": f"{seq_id}_T{i:02d}", "class": o["cls"], "bbox": [x, y, s, s],
|
|
"pattern": "dive" if o["dive"] else ("swarm" if kind == "swarm" and o["cls"] == "drone" else "normal")})
|
|
frames[t] = _noise(canvas, cond, rng)
|
|
return frames, labels
|
|
|
|
|
|
def make_dataset(n=24, seed=7):
|
|
rng = np.random.default_rng(seed)
|
|
conds = ["clear", "night", "fog", "rain"]
|
|
out = []
|
|
for i in range(n):
|
|
kind = "swarm" if i % 8 == 5 else ("dive" if i % 8 == 6 else "mixed")
|
|
f, lab = make_sequence(f"SEQ{i:03d}", conds[i % 4], rng, kind)
|
|
out.append({"id": f"SEQ{i:03d}", "cond": conds[i % 4], "kind": kind, "frames": f, "labels": lab})
|
|
return out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---- 전처리·탐지 공통 함수 ----
|
|
def preprocess(frame, cond):
|
|
f = frame.astype(float)
|
|
if cond == "rain": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)
|
|
f = ndimage.grey_opening(f, size=(1, 2))
|
|
lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장
|
|
return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)
|
|
|
|
|
|
def detect(frame, cond, k=2.0, min_area=3, max_area=60):
|
|
img = preprocess(frame, cond)
|
|
lab, _ = ndimage.label(img > img.mean() + k * img.std())
|
|
dets = []
|
|
for i, sl in enumerate(ndimage.find_objects(lab), start=1):
|
|
area = int((lab[sl] == i).sum())
|
|
if min_area <= area <= max_area:
|
|
y, x = sl[0].start, sl[1].start
|
|
dets.append({"bbox": [x, y, sl[1].stop - x, sl[0].stop - y], "area": area,
|
|
"thermal": float(frame[sl][lab[sl] == i].max())})
|
|
return dets
|
|
|
|
|
|
def iou(a, b):
|
|
ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
|
|
iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
|
|
inter = ix * iy
|
|
return inter / (a[2] * a[3] + b[2] * b[3] - inter)
|
|
|
|
# ---- 추적·특징·패턴 공통 함수 ----
|
|
def center(b):
|
|
return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])
|
|
|
|
|
|
def track(per_frame, gate=6.0, max_missed=2, min_len=5):
|
|
"""예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다."""
|
|
active, done = [], []
|
|
for t, dets in enumerate(per_frame):
|
|
used = set()
|
|
for tr in sorted(active, key=lambda x: -len(x["t"])):
|
|
last = center(tr["b"][-1])
|
|
vel = last - center(tr["b"][-2]) if len(tr["b"]) > 1 else np.zeros(2)
|
|
pred = last + vel * (t - tr["t"][-1])
|
|
cand = [(np.linalg.norm(center(d["bbox"]) - pred), j) for j, d in enumerate(dets) if j not in used]
|
|
dist, j = min(cand, default=(gate, -1))
|
|
if j >= 0 and dist < gate:
|
|
used.add(j)
|
|
tr["t"].append(t); tr["b"].append(dets[j]["bbox"])
|
|
tr["area"].append(dets[j]["area"]); tr["heat"].append(dets[j]["thermal"])
|
|
still = []
|
|
for tr in active:
|
|
(still if t - tr["t"][-1] <= max_missed else done).append(tr)
|
|
active = still
|
|
for j, d in enumerate(dets):
|
|
if j not in used:
|
|
active.append({"t": [t], "b": [d["bbox"]], "area": [d["area"]], "heat": [d["thermal"]]})
|
|
return [tr for tr in done + active if len(tr["t"]) >= min_len]
|
|
|
|
|
|
FEATURES = ["mean_speed", "speed_std", "turn", "straight", "vy_accel", "area", "heat", "heat_std"]
|
|
|
|
|
|
def features(tr):
|
|
c = np.array([center(b) for b in tr["b"]]); f = np.array(tr["t"], float)
|
|
v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]
|
|
sp = np.linalg.norm(v, axis=1)
|
|
k = 3 if len(c) > 4 else 1
|
|
v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]
|
|
hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2
|
|
turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]
|
|
half = len(v) // 2
|
|
return {"mean_speed": sp.mean(), "speed_std": sp.std(), "turn": turns.mean() if len(turns) else 0.0,
|
|
"straight": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),
|
|
"vy_accel": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,
|
|
"area": np.mean(tr["area"]), "heat": np.mean(tr["heat"]), "heat_std": np.std(tr["heat"]),
|
|
"vx": v[:, 0].mean(), "vy": v[:, 1].mean(), "x": c[-1, 0], "y": c[-1, 1]}
|
|
|
|
|
|
def pattern(f):
|
|
if f["mean_speed"] < 0.25:
|
|
return "static"
|
|
if f["vy_accel"] > 0.06:
|
|
return "dive"
|
|
if f["turn"] > 0.4 or f["straight"] < 0.6:
|
|
return "erratic"
|
|
return "steady"
|
|
|
|
|
|
def swarms(trs, vel=0.2, dist=18.0, min_n=3):
|
|
"""속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다."""
|
|
cand = [i for i, t in enumerate(trs) if t["pattern"] != "static"]
|
|
adj = {i: set() for i in cand}
|
|
for a in cand:
|
|
for b in cand:
|
|
fa, fb = trs[a]["f"], trs[b]["f"]
|
|
if a < b and np.hypot(fa["vx"] - fb["vx"], fa["vy"] - fb["vy"]) < vel and np.hypot(fa["x"] - fb["x"], fa["y"] - fb["y"]) < dist:
|
|
adj[a].add(b); adj[b].add(a)
|
|
seen, groups = set(), []
|
|
for i in cand:
|
|
if i in seen:
|
|
continue
|
|
stack, comp = [i], []
|
|
while stack:
|
|
k = stack.pop()
|
|
if k not in seen:
|
|
seen.add(k); comp.append(k); stack += list(adj[k] - seen)
|
|
if len(comp) >= min_n:
|
|
groups.append(comp)
|
|
return groups
|
|
|
|
|
|
def analyze(s, k=2.0):
|
|
"""시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용)."""
|
|
per = [detect(fr, s["cond"], k) if fr is not None else [] for fr in s["frames"]]
|
|
trs = track(per)
|
|
for tr in trs:
|
|
tr["f"] = features(tr); tr["pattern"] = pattern(tr["f"]); tr["swarm"] = None; tr["seq"] = s["id"]
|
|
votes = {}
|
|
for t, b in zip(tr["t"], tr["b"]):
|
|
best = max(((iou(b, l["bbox"]), l) for l in s["labels"] if l["frame"] == t), default=(0, None), key=lambda x: x[0])
|
|
key = (best[1]["track"], best[1]["class"], best[1]["pattern"]) if best[0] > 0.05 else ("none", "noise", "normal")
|
|
votes[key] = votes.get(key, 0) + 1
|
|
tr["gt_track"], tr["gt_class"], tr["gt_pattern"] = max(votes, key=votes.get)
|
|
for g, comp in enumerate(swarms(trs)):
|
|
for i in comp:
|
|
trs[i]["swarm"] = g
|
|
if trs[i]["pattern"] in ("steady", "erratic"):
|
|
trs[i]["pattern"] = "swarm"
|
|
return trs
|
|
|
|
|
|
def X_of(trs):
|
|
return np.array([[t["f"][k] for k in FEATURES] for t in trs])
|
|
|
|
|
|
def grade(p):
|
|
return "High" if p >= 0.8 else ("Medium" if p >= 0.5 else "Low")
|
|
|
|
|
|
def stats(trs, alert_key):
|
|
alerts = [t for t in trs if alert_key(t)]
|
|
true = [t for t in alerts if t["gt_class"] == "drone"]
|
|
drones = {t["gt_track"] for t in trs if t["gt_class"] == "drone"}
|
|
far = (len(alerts) - len(true)) / max(len(alerts), 1)
|
|
return {"경보 수": len(alerts), "드론 경보": len(true), "오경보율": round(far, 3),
|
|
"드론 재현율": round(len({t["gt_track"] for t in true}) / max(len(drones), 1), 3)}
|
|
|
|
|
|
train = [t for s in make_dataset(24, seed=7) for t in analyze(s)] # 학습용
|
|
test = [t for s in make_dataset(24, seed=11) for t in analyze(s)] # 검증용(학습에 쓰지 않음)
|
|
|
|
model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=1000, random_state=0))
|
|
model.fit(X_of(train), [1 if t["gt_class"] == "drone" else 0 for t in train])
|
|
for t, p in zip(test, model.predict_proba(X_of(test))[:, 1]):
|
|
t["score"] = 0.0 if t["pattern"] == "static" else float(p)
|
|
t["grade"] = grade(t["score"])
|
|
|
|
before = stats(test, lambda t: t["pattern"] != "static") # 기존: 움직이면 모두 경보
|
|
after = stats(test, lambda t: t["pattern"] != "static" and t["score"] >= 0.5)
|
|
print(pd.DataFrame([{"방식": "기존(움직이면 경보)", **before}, {"방식": "신뢰도 엔진(0.5 이상)", **after}]).to_string(index=False))
|
|
cut = 1 - after["오경보율"] / max(before["오경보율"], 1e-9)
|
|
print(f"\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})")
|
|
|
|
# 점수 일관성: 같은 입력을 다시 넣었을 때 같은 등급이 나오는 비율
|
|
again = [grade(0.0 if t["pattern"] == "static" else p) for t, p in zip(test, model.predict_proba(X_of(test))[:, 1])]
|
|
print(f"신뢰도 점수 산출 일관성 {np.mean([a == t['grade'] for a, t in zip(again, test)]):.0%}")
|
|
|
|
print("\n[실제 정체별 등급 분포]")
|
|
print(pd.crosstab(pd.Series([t["gt_class"] for t in test], name="실제"), pd.Series([t["grade"] for t in test], name="등급")))
|
|
|
|
# 판단 근거: 점수에 가장 크게 기여한 특징
|
|
scaler, lr = model[0], model[-1]
|
|
top = sorted(test, key=lambda t: -t["score"])[0]
|
|
contrib = (np.array([top["f"][k] for k in FEATURES]) - scaler.mean_) / scaler.scale_ * lr.coef_[0]
|
|
print(f"\n예시 경보 {top['seq']} (점수 {top['score']:.2f}) 판단 근거:",
|
|
", ".join(f"{FEATURES[i]}({contrib[i]:+.2f})" for i in np.argsort(-np.abs(contrib))[:3]))
|
|
print("점수는 경보 확정이 아니라 운용자가 먼저 볼 순서를 정하는 용도다.")
|
|
|
|
plt.figure(figsize=(7, 4))
|
|
for cls, col in [("drone", "tab:red"), ("bird", "tab:blue"), ("civil", "tab:green"), ("clutter", "gray")]:
|
|
plt.hist([t["score"] for t in test if t["gt_class"] == cls], bins=10, range=(0, 1), alpha=0.6, label=cls, color=col)
|
|
plt.axvline(0.5, color="k", ls="--"); plt.xlabel("confidence score"); plt.ylabel("tracks"); plt.legend()
|
|
plt.title("Confidence score by true class (synthetic)")
|
|
plt.show()
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
방식 경보 수 드론 경보 오경보율 드론 재현율
|
|
기존(움직이면 경보) 80 44 0.450 1.000
|
|
신뢰도 엔진(0.5 이상) 45 43 0.044 0.977
|
|
|
|
오경보율 감축 90.2% (목표 40% 이상: 달성)
|
|
신뢰도 점수 산출 일관성 100%
|
|
|
|
[실제 정체별 등급 분포]
|
|
등급 High Low Medium
|
|
실제
|
|
bird 0 23 2
|
|
civil 0 9 0
|
|
clutter 0 15 0
|
|
drone 37 1 6
|
|
|
|
예시 경보 SEQ000 (점수 1.00) 판단 근거: heat(+2.97), area(+1.43), straight(+1.14)
|
|
점수는 경보 확정이 아니라 운용자가 먼저 볼 순서를 정하는 용도다.
|
|
```
|
|
|
|
## 미션 6. 실시간 공중위협 경보 및 방공 사격구역 가시화 전술 대시보드 구현
|
|
|
|
- 난이도: 중급 / 예상 소요시간: 2.5시간
|
|
- 코드 설명: 지휘관·방공 통제관용 공중위협 상황판(COP)을 구현한다. 가상 표적의 방위·고도·속도와 궤적 외삽으로 구한 예상 탄착점을 방어 지점 기준 방공 사격구역(가상 원형 구역)과 함께 표시하고, 긴급 경보를 강조한다. 화면 갱신 처리시간도 측정한다.
|
|
|
|
```python
|
|
"""지휘관·방공 통제관용 공중위협 상황판(COP)을 구현한다. 가상 표적의 방위·고도·속도와 궤적 외삽으로 구한 예상 탄착점을 방어 지점 기준 방공 사격구역(가상 원형 구역)과 함께 표시하고, 긴급 경보를 강조한다. 화면 갱신 처리시간도 측정한다."""
|
|
import time
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
|
|
# [교육용가상자료] 방어 지점(0,0) 기준 가상 표적 12개: 위치(km), 고도(m), 속도 벡터(m/s)
|
|
rng = np.random.default_rng(3)
|
|
N = 12
|
|
kinds = rng.choice(["recon_drone", "attack_drone", "cruise_missile", "bird"], N, p=[0.35, 0.35, 0.15, 0.15])
|
|
pos = rng.uniform(-12, 12, (N, 2))
|
|
alt = np.where(kinds == "cruise_missile", rng.uniform(50, 150, N), rng.uniform(100, 1500, N))
|
|
spd = np.select([kinds == "cruise_missile", kinds == "bird"], [rng.uniform(200, 250, N), rng.uniform(8, 15, N)], rng.uniform(25, 50, N))
|
|
to_center = -pos / np.linalg.norm(pos, axis=1, keepdims=True)
|
|
heading = to_center + rng.normal(0, 0.35, (N, 2))
|
|
heading /= np.linalg.norm(heading, axis=1, keepdims=True)
|
|
vel = heading * spd[:, None] # 수평 속도(m/s)
|
|
vz = np.where(kinds == "attack_drone", -rng.uniform(3, 8, N), np.where(kinds == "bird", 0, -rng.uniform(0, 1, N)))
|
|
score = np.select([kinds == "bird"], [rng.uniform(0.05, 0.4, N)], rng.uniform(0.55, 0.98, N)) # 미션 5 신뢰도 점수 가정
|
|
|
|
FIRE_ZONE_KM = 5.0 # 가상 방공 사격구역 반경
|
|
|
|
|
|
def build_picture(pos, alt, vel, vz):
|
|
rows = []
|
|
for i in range(N):
|
|
az = (np.degrees(np.arctan2(pos[i, 0], pos[i, 1])) + 360) % 360
|
|
rng_km = float(np.linalg.norm(pos[i]))
|
|
closing = -float(np.dot(pos[i], vel[i]) / max(rng_km, 1e-6)) / 1000 # km/s 접근 속도
|
|
tti = rng_km / closing if closing > 0 else np.inf
|
|
if vz[i] < -2: # 하강 중이면 고도 0 도달 지점(예상 탄착점) 외삽
|
|
t_hit = alt[i] / -vz[i]
|
|
impact = pos[i] + vel[i] * t_hit / 1000
|
|
else:
|
|
impact = np.array([np.nan, np.nan])
|
|
in_zone = rng_km <= FIRE_ZONE_KM
|
|
rows.append({"ID": f"T{i:02d}", "유형(추정)": kinds[i], "방위(도)": round(az), "거리(km)": round(rng_km, 1),
|
|
"고도(m)": int(alt[i]), "속도(m/s)": int(np.linalg.norm(vel[i])), "도달예상(s)": None if np.isinf(tti) else int(tti),
|
|
"예상탄착점(km)": None if np.isnan(impact[0]) else f"({impact[0]:.1f}, {impact[1]:.1f})",
|
|
"사격구역 내": in_zone, "신뢰도": round(float(score[i]), 2),
|
|
"긴급": bool(score[i] >= 0.5 and (in_zone or (not np.isinf(tti) and tti < 120)))})
|
|
return pd.DataFrame(rows), [None if np.isnan(r[0]) else r for r in
|
|
[pos[i] + vel[i] * (alt[i] / -vz[i]) / 1000 if vz[i] < -2 else np.array([np.nan, np.nan]) for i in range(N)]]
|
|
|
|
|
|
# 1) 1초 주기 갱신 모의: 5회 갱신하며 처리시간 측정
|
|
times = []
|
|
for step in range(5):
|
|
t0 = time.perf_counter()
|
|
table, impacts = build_picture(pos, alt, vel, vz)
|
|
times.append((time.perf_counter() - t0) * 1000)
|
|
pos = pos + vel / 1000 * 1.0 # 1초 경과
|
|
alt = np.maximum(alt + vz, 0)
|
|
print(f"상황판 갱신 처리시간 평균 {np.mean(times):.1f} ms (목표: 갱신 주기 1초 이내, 응답 100 ms 이내)")
|
|
print("\n[공중위협 상황 목록: 긴급 경보 우선]")
|
|
print(table.sort_values(["긴급", "신뢰도"], ascending=False).fillna("-").to_string(index=False))
|
|
|
|
# 2) 전술 상황판 그림 (야간 운용을 고려한 어두운 배경, 긴급 경보는 굵게 강조)
|
|
plt.style.use("dark_background")
|
|
fig, ax = plt.subplots(1, 2, figsize=(13, 6), gridspec_kw={"width_ratios": [1.2, 1]})
|
|
a = ax[0]
|
|
a.add_patch(plt.Circle((0, 0), FIRE_ZONE_KM, color="orange", alpha=0.15, label="air-defense zone (virtual)"))
|
|
a.plot(0, 0, "w^", ms=10, label="defended point")
|
|
for i, r in table.iterrows():
|
|
c = "red" if r["긴급"] else ("gray" if kinds[i] == "bird" else "deepskyblue")
|
|
a.arrow(pos[i, 0], pos[i, 1], vel[i, 0] / 25, vel[i, 1] / 25, color=c, width=0.08, head_width=0.4)
|
|
a.text(pos[i, 0] + 0.3, pos[i, 1] + 0.3, f"{r['ID']} {r['고도(m)']}m", color=c, fontsize=8,
|
|
fontweight="bold" if r["긴급"] else "normal")
|
|
if impacts[i] is not None:
|
|
a.plot(*impacts[i], "x", color=c, ms=9)
|
|
a.set_xlim(-15, 15); a.set_ylim(-15, 15); a.set_aspect("equal"); a.grid(alpha=0.2)
|
|
a.set_title("Air threat picture (x = predicted impact point)"); a.set_xlabel("East (km)"); a.set_ylabel("North (km)")
|
|
a.legend(loc="lower left", fontsize=8)
|
|
b = ax[1]
|
|
counts = table.groupby("유형(추정)")["ID"].count()
|
|
b.barh(counts.index, counts.values, color="deepskyblue"); b.set_title(f"Threats by type (urgent: {int(table['긴급'].sum())})")
|
|
plt.suptitle("Tactical COP - training data only")
|
|
plt.tight_layout(); plt.show()
|
|
print("\n표시된 경보·탄착점은 판단 보조 정보이며, 교전 여부는 미션 7의 인간 승인 절차와 실제 교전수칙·지휘계통에 따른다.")
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
상황판 갱신 처리시간 평균 1.3 ms (목표: 갱신 주기 1초 이내, 응답 100 ms 이내)
|
|
|
|
[공중위협 상황 목록: 긴급 경보 우선]
|
|
ID 유형(추정) 방위(도) 거리(km) 고도(m) 속도(m/s) 도달예상(s) 예상탄착점(km) 사격구역 내 신뢰도 긴급
|
|
T00 recon_drone 321 2.5 1287 49 50 - True 0.98 True
|
|
T08 cruise_missile 296 11.4 91 248 53 - False 0.95 True
|
|
T02 cruise_missile 308 5.4 120 237 25 - False 0.78 True
|
|
T03 attack_drone 137 6.7 276 38 173 (3.5, -3.8) False 0.93 False
|
|
T07 recon_drone 354 6.5 483 34 193 - False 0.88 False
|
|
T05 attack_drone 227 6.5 491 26 265 (-1.9, -3.3) False 0.80 False
|
|
T09 recon_drone 196 10.2 656 41 450 - False 0.78 False
|
|
T06 attack_drone 78 9.5 1297 41 225 (-0.5, -0.6) False 0.62 False
|
|
T01 recon_drone 27 12.2 651 42 310 - False 0.61 False
|
|
T11 attack_drone 293 7.6 350 38 217 (-5.4, 1.4) False 0.58 False
|
|
T04 recon_drone 314 16.3 1074 42 382 - False 0.57 False
|
|
T10 attack_drone 20 10.9 939 41 260 (0.6, 2.3) False 0.55 False
|
|
|
|
표시된 경보·탄착점은 판단 보조 정보이며, 교전 여부는 미션 7의 인간 승인 절차와 실제 교전수칙·지휘계통에 따른다.
|
|
```
|
|
|
|
## 미션 7. 긴급 요격 결심 지원을 위한 인간 개입(HITL) 교전 승인/보류 워크플로우 설계
|
|
|
|
- 난이도: 고급 / 예상 소요시간: 3.0시간
|
|
- 코드 설명: 자동 탐지 표적을 방공작전관이 직접 승인·보류·반려하는 인간 개입(HITL) 교전 승인/보류 워크플로우를 구현한다. AI는 자동 승인하지 않으며, 모든 결정은 운용자 ID·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다.
|
|
|
|
```python
|
|
"""자동 탐지 표적을 방공작전관이 직접 승인·보류·반려하는 인간 개입(HITL) 교전 승인/보류 워크플로우를 구현한다. AI는 자동 승인하지 않으며, 모든 결정은 운용자 ID·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다."""
|
|
import hashlib
|
|
import json
|
|
import time
|
|
import pandas as pd
|
|
|
|
ACTIONS = {"APPROVE": "승인(상급 지휘계통으로 인가 요청)", "HOLD": "보류(추가 확인)", "REJECT": "반려(오표적·민간기 등)"}
|
|
PRIORITY = {"High": 0, "Medium": 1, "Low": 2}
|
|
HOLD_LIMIT_S = 60
|
|
|
|
|
|
class WorkflowError(ValueError):
|
|
pass
|
|
|
|
|
|
class ApprovalQueue:
|
|
def __init__(self, clock=time.time):
|
|
self.items, self.log, self.clock = {}, [], clock
|
|
|
|
# ---- 감사 로그: 직전 해시를 포함한 해시 체인(추가 전용) ----
|
|
def _record(self, body):
|
|
prev = self.log[-1]["hash"] if self.log else "GENESIS"
|
|
body = {**body, "ts": round(self.clock(), 3)}
|
|
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_log(self):
|
|
prev = "GENESIS"
|
|
for n, rec in enumerate(self.log, start=1):
|
|
body = {k: v for k, v in rec.items() if k not in ("prev", "hash")}
|
|
if rec["prev"] != prev or rec["hash"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():
|
|
return False, n
|
|
prev = rec["hash"]
|
|
return True, -1
|
|
|
|
# ---- 대기열 ----
|
|
def submit(self, target_id, score, grade):
|
|
"""AI 탐지 표적 등록. 점수와 무관하게 항상 PENDING(검토 대기)으로 시작한다."""
|
|
self.items[target_id] = {"id": target_id, "score": score, "grade": grade, "status": "PENDING",
|
|
"submitted": self.clock(), "hold_since": None, "escalated": False}
|
|
self._record({"event": "SUBMIT", "id": target_id, "score": score, "by": "AI"})
|
|
|
|
def pending(self):
|
|
todo = [i for i in self.items.values() if i["status"] in ("PENDING", "HOLD")]
|
|
return sorted(todo, key=lambda i: (not i["escalated"], PRIORITY[i["grade"]], -i["score"]))
|
|
|
|
def decide(self, target_id, action, operator, reason):
|
|
if action not in ACTIONS:
|
|
raise WorkflowError(f"허용되지 않은 조치: {action}")
|
|
if not operator:
|
|
raise WorkflowError("운용자 ID 필요")
|
|
item = self.items.get(target_id)
|
|
if item is None:
|
|
raise WorkflowError("대기열에 없는 표적")
|
|
if item["status"] in ("APPROVED", "REJECTED"):
|
|
raise WorkflowError("이미 종결된 표적")
|
|
if len(reason.strip()) < 5:
|
|
raise WorkflowError("판단 근거 5자 이상 필요")
|
|
if action == "APPROVE" and item["grade"] == "Low" and len(reason.strip()) < 15:
|
|
raise WorkflowError("Low 등급 승인은 근거 15자 이상 필요(오인 방지)")
|
|
item["status"] = {"APPROVE": "APPROVED", "HOLD": "HOLD", "REJECT": "REJECTED"}[action]
|
|
item["hold_since"] = self.clock() if action == "HOLD" else None
|
|
item["decided"] = self.clock()
|
|
self._record({"event": "DECIDE", "id": target_id, "action": action, "operator": operator, "reason": reason})
|
|
|
|
def check_holds(self):
|
|
"""보류 기한 초과 시 자동 승인하지 않고 상급 검토자에게 상신한다."""
|
|
out = []
|
|
for i in self.items.values():
|
|
if i["status"] == "HOLD" and not i["escalated"] and self.clock() - i["hold_since"] > HOLD_LIMIT_S:
|
|
i["escalated"] = True; out.append(i["id"])
|
|
self._record({"event": "ESCALATE", "id": i["id"], "by": "SYSTEM"})
|
|
return out
|
|
|
|
|
|
class FakeClock:
|
|
t = 1000.0
|
|
|
|
def __call__(self):
|
|
return self.t
|
|
|
|
|
|
clock = FakeClock()
|
|
q = ApprovalQueue(clock)
|
|
# [교육용가상자료] 미션 5에서 넘어온 표적 5개
|
|
for tid, s, g in [("T01", 0.97, "High"), ("T02", 0.62, "Medium"), ("T03", 0.44, "Low"), ("T04", 0.91, "High"), ("T05", 0.55, "Medium")]:
|
|
q.submit(tid, s, g)
|
|
print("검토 순서:", [i["id"] for i in q.pending()])
|
|
print("자동 승인된 표적 수:", sum(i["status"] == "APPROVED" for i in q.items.values()))
|
|
|
|
# 규칙 위반 시도들이 모두 차단되는지 확인
|
|
for args in [("T01", "APPROVE", "", "근거 충분함"), ("T03", "APPROVE", "op01", "직진 궤적"), ("T01", "FIRE", "op01", "허용되지 않은 조치")]:
|
|
try:
|
|
q.decide(*args)
|
|
except WorkflowError as e:
|
|
print("차단:", e)
|
|
|
|
clock.t += 3; q.decide("T01", "APPROVE", "op01", "열신호 높고 직진, 방어지점 접근 확인")
|
|
clock.t += 5; q.decide("T04", "HOLD", "op01", "민간 항공 운항정보 확인 필요")
|
|
clock.t += 4; q.decide("T03", "REJECT", "op02", "날갯짓 열신호 변동, 조류 판정")
|
|
clock.t += 2; q.decide("T02", "HOLD", "op02", "추가 영상 확인")
|
|
clock.t += 70
|
|
print("보류 기한 초과 상신:", q.check_holds())
|
|
print("상신 후 검토 순서:", [i["id"] for i in q.pending()])
|
|
|
|
# 처리 결과 요약
|
|
df = pd.DataFrame(q.items.values())
|
|
df["대기시간(s)"] = (df["decided"] - df["submitted"]).round(1) if "decided" in df else None
|
|
print("\n" + df[["id", "grade", "score", "status", "escalated", "대기시간(s)"]].fillna("-").to_string(index=False))
|
|
print(f"\n감사 로그 {len(q.log)}건 기록, 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {q.verify_log()}")
|
|
q.log[4]["operator"] = "op99" # 위·변조 시도: 5번째 기록의 운용자 ID 변경
|
|
print("5번째 기록 변조 후 검증(False, 깨진 위치):", q.verify_log())
|
|
print("\n본 워크플로우의 '승인'은 상급 지휘계통으로 인가를 요청하는 단계까지이며, 실제 교전은 교전수칙·지휘계통에 따른다.")
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
검토 순서: ['T01', 'T04', 'T02', 'T05', 'T03']
|
|
자동 승인된 표적 수: 0
|
|
차단: 운용자 ID 필요
|
|
차단: Low 등급 승인은 근거 15자 이상 필요(오인 방지)
|
|
차단: 허용되지 않은 조치: FIRE
|
|
보류 기한 초과 상신: ['T02', 'T04']
|
|
상신 후 검토 순서: ['T04', 'T02', 'T05']
|
|
|
|
id grade score status escalated 대기시간(s)
|
|
T01 High 0.97 APPROVED False 3.0
|
|
T02 Medium 0.62 HOLD True 14.0
|
|
T03 Low 0.44 REJECTED False 12.0
|
|
T04 High 0.91 HOLD True 8.0
|
|
T05 Medium 0.55 PENDING False -
|
|
|
|
감사 로그 11건 기록, 로깅 성공률 100%, 해시 체인 검증: (True, -1)
|
|
5번째 기록 변조 후 검증(False, 깨진 위치): (False, 5)
|
|
|
|
본 워크플로우의 '승인'은 상급 지휘계통으로 인가를 요청하는 단계까지이며, 실제 교전은 교전수칙·지휘계통에 따른다.
|
|
```
|
|
|
|
## 미션 8. 대규모 드론 포화 공격 상황 모의 가상 스트레스 시뮬레이션 및 복원력 테스트
|
|
|
|
- 난이도: 고급 / 예상 소요시간: 3.5시간
|
|
- 코드 설명: 100기 이상 드론 포화 공격, 프레임 지연·손실, 통신 패킷 손실을 가상으로 주입해 탐지 파이프라인이 멈추지 않는지(다운 0건), 경보가 유실되지 않는지(유실률 0%), 처리 지연과 성능 저하 모드가 어떻게 변하는지 검증한다.
|
|
|
|
```python
|
|
"""100기 이상 드론 포화 공격, 프레임 지연·손실, 통신 패킷 손실을 가상으로 주입해 탐지 파이프라인이 멈추지 않는지(다운 0건), 경보가 유실되지 않는지(유실률 0%), 처리 지연과 성능 저하 모드가 어떻게 변하는지 검증한다."""
|
|
import time
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
from scipy import ndimage
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)
|
|
# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.
|
|
# ---------------------------------------------------------------------------
|
|
H = W = 64
|
|
PROFILE = {"drone": ((2, 3), (170, 225)), "bird": ((2, 3), (110, 150)),
|
|
"civil": ((4, 6), (190, 240)), "clutter": ((2, 4), (140, 190))}
|
|
|
|
|
|
def _new_obj(cls, rng, dive=False, v=None, xy=None):
|
|
(s0, s1), (h0, h1) = PROFILE[cls]
|
|
x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))
|
|
speed = {"drone": 1.3, "bird": 0.9, "civil": 0.35, "clutter": 0.0}[cls]
|
|
vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)
|
|
return {"cls": cls, "size": int(rng.integers(s0, s1 + 1)), "heat": float(rng.uniform(h0, h1)),
|
|
"x": float(x), "y": float(y), "vx": float(vx), "vy": float(vy), "dive": dive}
|
|
|
|
|
|
def _noise(img, cond, rng):
|
|
f = img.astype(float)
|
|
if cond == "night":
|
|
f = f * 0.8 + rng.normal(0, 14, f.shape)
|
|
elif cond == "fog":
|
|
f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)
|
|
elif cond == "rain":
|
|
f = f + rng.normal(0, 8, f.shape)
|
|
for _ in range(20):
|
|
x, y0 = rng.integers(0, W), rng.integers(0, H - 8)
|
|
f[y0:y0 + 8, x] += 45
|
|
else:
|
|
f = f + rng.normal(0, 6, f.shape)
|
|
return np.clip(f, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def make_sequence(seq_id, cond, rng, kind="mixed", n_frames=30, n_drone=None, n_bird=None):
|
|
"""시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation"""
|
|
objs = []
|
|
if kind == "swarm":
|
|
v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)
|
|
objs = [_new_obj("drone", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]
|
|
objs.append(_new_obj("bird", rng))
|
|
elif kind == "dive":
|
|
d = _new_obj("drone", rng, dive=True)
|
|
d["y"] = float(rng.uniform(4, 14))
|
|
objs = [d, _new_obj("bird", rng), _new_obj("clutter", rng)]
|
|
elif kind == "saturation":
|
|
objs = [_new_obj("drone", rng) for _ in range(n_drone)] + [_new_obj("bird", rng) for _ in range(n_bird)]
|
|
else:
|
|
objs = [_new_obj("drone", rng) for _ in range(int(rng.integers(1, 3)))]
|
|
objs += [_new_obj("bird", rng) for _ in range(int(rng.integers(0, 3)))]
|
|
if rng.random() < 0.5:
|
|
objs.append(_new_obj("civil", rng))
|
|
if rng.random() < 0.6:
|
|
objs.append(_new_obj("clutter", rng))
|
|
frames, labels = np.zeros((n_frames, H, W), np.uint8), []
|
|
base = rng.normal(40, 6, (H, W))
|
|
for t in range(n_frames):
|
|
canvas = base.copy()
|
|
for i, o in enumerate(objs):
|
|
if t > 0:
|
|
if o["cls"] == "bird":
|
|
o["vx"] = float(np.clip(o["vx"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
o["vy"] = float(np.clip(o["vy"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
if o["dive"] and t > 10:
|
|
o["vy"] += 0.18
|
|
o["x"] = float(np.clip(o["x"] + o["vx"], 1, W - o["size"] - 1))
|
|
o["y"] = float(np.clip(o["y"] + o["vy"], 1, H - o["size"] - 1))
|
|
heat = o["heat"]
|
|
if o["cls"] == "clutter" and rng.random() < 0.3:
|
|
heat *= 0.7
|
|
if o["cls"] == "bird":
|
|
heat *= float(rng.uniform(0.85, 1.1))
|
|
x, y, s = int(round(o["x"])), int(round(o["y"])), o["size"]
|
|
canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)
|
|
labels.append({"frame": t, "track": f"{seq_id}_T{i:02d}", "class": o["cls"], "bbox": [x, y, s, s],
|
|
"pattern": "dive" if o["dive"] else ("swarm" if kind == "swarm" and o["cls"] == "drone" else "normal")})
|
|
frames[t] = _noise(canvas, cond, rng)
|
|
return frames, labels
|
|
|
|
|
|
def make_dataset(n=24, seed=7):
|
|
rng = np.random.default_rng(seed)
|
|
conds = ["clear", "night", "fog", "rain"]
|
|
out = []
|
|
for i in range(n):
|
|
kind = "swarm" if i % 8 == 5 else ("dive" if i % 8 == 6 else "mixed")
|
|
f, lab = make_sequence(f"SEQ{i:03d}", conds[i % 4], rng, kind)
|
|
out.append({"id": f"SEQ{i:03d}", "cond": conds[i % 4], "kind": kind, "frames": f, "labels": lab})
|
|
return out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---- 전처리·탐지 공통 함수 ----
|
|
def preprocess(frame, cond):
|
|
f = frame.astype(float)
|
|
if cond == "rain": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)
|
|
f = ndimage.grey_opening(f, size=(1, 2))
|
|
lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장
|
|
return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)
|
|
|
|
|
|
def detect(frame, cond, k=2.0, min_area=3, max_area=60):
|
|
img = preprocess(frame, cond)
|
|
lab, _ = ndimage.label(img > img.mean() + k * img.std())
|
|
dets = []
|
|
for i, sl in enumerate(ndimage.find_objects(lab), start=1):
|
|
area = int((lab[sl] == i).sum())
|
|
if min_area <= area <= max_area:
|
|
y, x = sl[0].start, sl[1].start
|
|
dets.append({"bbox": [x, y, sl[1].stop - x, sl[0].stop - y], "area": area,
|
|
"thermal": float(frame[sl][lab[sl] == i].max())})
|
|
return dets
|
|
|
|
|
|
def iou(a, b):
|
|
ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
|
|
iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
|
|
inter = ix * iy
|
|
return inter / (a[2] * a[3] + b[2] * b[3] - inter)
|
|
|
|
# ---- 추적·특징·패턴 공통 함수 ----
|
|
def center(b):
|
|
return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])
|
|
|
|
|
|
def track(per_frame, gate=6.0, max_missed=2, min_len=5):
|
|
"""예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다."""
|
|
active, done = [], []
|
|
for t, dets in enumerate(per_frame):
|
|
used = set()
|
|
for tr in sorted(active, key=lambda x: -len(x["t"])):
|
|
last = center(tr["b"][-1])
|
|
vel = last - center(tr["b"][-2]) if len(tr["b"]) > 1 else np.zeros(2)
|
|
pred = last + vel * (t - tr["t"][-1])
|
|
cand = [(np.linalg.norm(center(d["bbox"]) - pred), j) for j, d in enumerate(dets) if j not in used]
|
|
dist, j = min(cand, default=(gate, -1))
|
|
if j >= 0 and dist < gate:
|
|
used.add(j)
|
|
tr["t"].append(t); tr["b"].append(dets[j]["bbox"])
|
|
tr["area"].append(dets[j]["area"]); tr["heat"].append(dets[j]["thermal"])
|
|
still = []
|
|
for tr in active:
|
|
(still if t - tr["t"][-1] <= max_missed else done).append(tr)
|
|
active = still
|
|
for j, d in enumerate(dets):
|
|
if j not in used:
|
|
active.append({"t": [t], "b": [d["bbox"]], "area": [d["area"]], "heat": [d["thermal"]]})
|
|
return [tr for tr in done + active if len(tr["t"]) >= min_len]
|
|
|
|
|
|
FEATURES = ["mean_speed", "speed_std", "turn", "straight", "vy_accel", "area", "heat", "heat_std"]
|
|
|
|
|
|
def features(tr):
|
|
c = np.array([center(b) for b in tr["b"]]); f = np.array(tr["t"], float)
|
|
v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]
|
|
sp = np.linalg.norm(v, axis=1)
|
|
k = 3 if len(c) > 4 else 1
|
|
v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]
|
|
hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2
|
|
turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]
|
|
half = len(v) // 2
|
|
return {"mean_speed": sp.mean(), "speed_std": sp.std(), "turn": turns.mean() if len(turns) else 0.0,
|
|
"straight": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),
|
|
"vy_accel": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,
|
|
"area": np.mean(tr["area"]), "heat": np.mean(tr["heat"]), "heat_std": np.std(tr["heat"]),
|
|
"vx": v[:, 0].mean(), "vy": v[:, 1].mean(), "x": c[-1, 0], "y": c[-1, 1]}
|
|
|
|
|
|
def pattern(f):
|
|
if f["mean_speed"] < 0.25:
|
|
return "static"
|
|
if f["vy_accel"] > 0.06:
|
|
return "dive"
|
|
if f["turn"] > 0.4 or f["straight"] < 0.6:
|
|
return "erratic"
|
|
return "steady"
|
|
|
|
|
|
def swarms(trs, vel=0.2, dist=18.0, min_n=3):
|
|
"""속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다."""
|
|
cand = [i for i, t in enumerate(trs) if t["pattern"] != "static"]
|
|
adj = {i: set() for i in cand}
|
|
for a in cand:
|
|
for b in cand:
|
|
fa, fb = trs[a]["f"], trs[b]["f"]
|
|
if a < b and np.hypot(fa["vx"] - fb["vx"], fa["vy"] - fb["vy"]) < vel and np.hypot(fa["x"] - fb["x"], fa["y"] - fb["y"]) < dist:
|
|
adj[a].add(b); adj[b].add(a)
|
|
seen, groups = set(), []
|
|
for i in cand:
|
|
if i in seen:
|
|
continue
|
|
stack, comp = [i], []
|
|
while stack:
|
|
k = stack.pop()
|
|
if k not in seen:
|
|
seen.add(k); comp.append(k); stack += list(adj[k] - seen)
|
|
if len(comp) >= min_n:
|
|
groups.append(comp)
|
|
return groups
|
|
|
|
|
|
def analyze(s, k=2.0):
|
|
"""시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용)."""
|
|
per = [detect(fr, s["cond"], k) if fr is not None else [] for fr in s["frames"]]
|
|
trs = track(per)
|
|
for tr in trs:
|
|
tr["f"] = features(tr); tr["pattern"] = pattern(tr["f"]); tr["swarm"] = None; tr["seq"] = s["id"]
|
|
votes = {}
|
|
for t, b in zip(tr["t"], tr["b"]):
|
|
best = max(((iou(b, l["bbox"]), l) for l in s["labels"] if l["frame"] == t), default=(0, None), key=lambda x: x[0])
|
|
key = (best[1]["track"], best[1]["class"], best[1]["pattern"]) if best[0] > 0.05 else ("none", "noise", "normal")
|
|
votes[key] = votes.get(key, 0) + 1
|
|
tr["gt_track"], tr["gt_class"], tr["gt_pattern"] = max(votes, key=votes.get)
|
|
for g, comp in enumerate(swarms(trs)):
|
|
for i in comp:
|
|
trs[i]["swarm"] = g
|
|
if trs[i]["pattern"] in ("steady", "erratic"):
|
|
trs[i]["pattern"] = "swarm"
|
|
return trs
|
|
|
|
|
|
QUEUE_CAPACITY = 30
|
|
|
|
|
|
def run(frames, cond, drop_rate=0.0, packet_loss=0.0, seed=0):
|
|
"""손실 주입 후 파이프라인 1회 실행. 예외가 나면 다운으로 기록한다."""
|
|
rng = np.random.default_rng(seed)
|
|
drop = set(rng.choice(len(frames), int(round(drop_rate * len(frames))), replace=False).tolist())
|
|
fr = [None if i in drop else f for i, f in enumerate(frames)]
|
|
t0 = time.perf_counter()
|
|
try:
|
|
s = {"id": "STRESS", "cond": cond, "frames": fr, "labels": []}
|
|
per = [detect(f, cond) if f is not None else [] for f in fr]
|
|
trs = track(per)
|
|
alerts = [t for t in trs if pattern(features(t)) != "static"]
|
|
sent = [a for a in alerts if rng.random() >= packet_loss] # 전송 중 손실
|
|
lost = [a for a in alerts if a not in sent]
|
|
resent = lost # 수신 확인 없는 경보는 재전송
|
|
received = len(sent) + len(resent)
|
|
down = 0
|
|
except Exception as e: # 다운 집계
|
|
alerts, received, down = [], 0, 1
|
|
print("다운:", e)
|
|
ms = (time.perf_counter() - t0) * 1000 / len(frames)
|
|
reasons = []
|
|
n_drop = sum(f is None for f in fr)
|
|
if n_drop / len(fr) > 0.05:
|
|
reasons.append(f"프레임 손실 {n_drop / len(fr):.0%}")
|
|
if len(alerts) > QUEUE_CAPACITY:
|
|
reasons.append(f"경보 {len(alerts)}건 > 검토용량 {QUEUE_CAPACITY}")
|
|
return {"경보": len(alerts), "수신": received, "유실률": 0 if not alerts else round(1 - received / len(alerts), 3),
|
|
"다운": down, "프레임당 ms": round(ms, 2), "성능저하 모드": "; ".join(reasons) or "-"}
|
|
|
|
|
|
rng = np.random.default_rng(1)
|
|
cases = []
|
|
base, _ = make_sequence("S0", "clear", rng, "mixed")
|
|
cases.append(("평시(비행체 1~5기)", base, "clear", 0, 0))
|
|
for n in [40, 80, 120]:
|
|
f, _ = make_sequence("S", "clear", rng, "saturation", n_drone=n, n_bird=n // 4)
|
|
cases.append((f"포화 드론 {n}기", f, "clear", 0, 0))
|
|
f120, _ = make_sequence("S", "rain", rng, "saturation", n_drone=120, n_bird=30)
|
|
cases.append(("포화 120기 + 비", f120, "rain", 0, 0))
|
|
cases.append(("포화 120기 + 프레임 10% 손실", f120, "rain", 0.10, 0))
|
|
cases.append(("포화 120기 + 패킷 20% 손실", f120, "rain", 0, 0.20))
|
|
cases.append(("전 프레임 손실(센서 두절)", f120, "rain", 1.0, 0))
|
|
|
|
rows = [{"상황": name, **run(fr, c, d, p, seed=i)} for i, (name, fr, c, d, p) in enumerate(cases)]
|
|
df = pd.DataFrame(rows)
|
|
print(df.to_string(index=False))
|
|
print(f"\n시스템 다운 {df['다운'].sum()}건, 최대 유실률 {df['유실률'].max():.0%}, 최대 프레임당 처리 {df['프레임당 ms'].max()} ms")
|
|
print("관찰: 64x64 영상에 120기가 겹치면 표적끼리 붙어 경보 수가 실제보다 적게 잡힌다(해상도 한계). 성능저하 모드가 표시되면 운용자 추가 투입·검토 우선순위 적용이 필요하다.")
|
|
|
|
plt.figure(figsize=(8, 4))
|
|
plt.bar(range(len(df)), df["프레임당 ms"], 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("ms per frame"); plt.title("Stress test latency (red = degraded mode)")
|
|
plt.show()
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
상황 경보 수신 유실률 다운 프레임당 ms 성능저하 모드
|
|
평시(비행체 1~5기) 1 1 0.0 0 0.89 -
|
|
포화 드론 40기 57 57 0.0 0 4.31 경보 57건 > 검토용량 30
|
|
포화 드론 80기 97 97 0.0 0 9.48 경보 97건 > 검토용량 30
|
|
포화 드론 120기 100 100 0.0 0 10.27 경보 100건 > 검토용량 30
|
|
포화 120기 + 비 96 96 0.0 0 11.60 경보 96건 > 검토용량 30
|
|
포화 120기 + 프레임 10% 손실 97 97 0.0 0 8.17 프레임 손실 10%; 경보 97건 > 검토용량 30
|
|
포화 120기 + 패킷 20% 손실 96 96 0.0 0 10.51 경보 96건 > 검토용량 30
|
|
전 프레임 손실(센서 두절) 0 0 0.0 0 0.00 프레임 손실 100%
|
|
|
|
시스템 다운 0건, 최대 유실률 0%, 최대 프레임당 처리 11.6 ms
|
|
관찰: 64x64 영상에 120기가 겹치면 표적끼리 붙어 경보 수가 실제보다 적게 잡힌다(해상도 한계). 성능저하 모드가 표시되면 운용자 추가 투입·검토 우선순위 적용이 필요하다.
|
|
```
|
|
|
|
## 미션 9. AAR 사후검토 기반 미탐·오탐 공중표적 오류 원인 분석 및 파라미터 재보정
|
|
|
|
- 난이도: 고급 / 예상 소요시간: 3.0시간
|
|
- 코드 설명: AAR(사후검토) 일지 형식으로 모의 운용 중 발생한 미탐·오경보 사례를 원인별로 분류하고, 탐지 임계값 k와 경보 임계값을 격자 탐색으로 재보정(역환류)해 개선 전후 성능과 재발 여부를 비교한다.
|
|
|
|
```python
|
|
"""AAR(사후검토) 일지 형식으로 모의 운용 중 발생한 미탐·오경보 사례를 원인별로 분류하고, 탐지 임계값 k와 경보 임계값을 격자 탐색으로 재보정(역환류)해 개선 전후 성능과 재발 여부를 비교한다."""
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
from scipy import ndimage
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.pipeline import make_pipeline
|
|
from sklearn.preprocessing import StandardScaler
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)
|
|
# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.
|
|
# ---------------------------------------------------------------------------
|
|
H = W = 64
|
|
PROFILE = {"drone": ((2, 3), (170, 225)), "bird": ((2, 3), (110, 150)),
|
|
"civil": ((4, 6), (190, 240)), "clutter": ((2, 4), (140, 190))}
|
|
|
|
|
|
def _new_obj(cls, rng, dive=False, v=None, xy=None):
|
|
(s0, s1), (h0, h1) = PROFILE[cls]
|
|
x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))
|
|
speed = {"drone": 1.3, "bird": 0.9, "civil": 0.35, "clutter": 0.0}[cls]
|
|
vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)
|
|
return {"cls": cls, "size": int(rng.integers(s0, s1 + 1)), "heat": float(rng.uniform(h0, h1)),
|
|
"x": float(x), "y": float(y), "vx": float(vx), "vy": float(vy), "dive": dive}
|
|
|
|
|
|
def _noise(img, cond, rng):
|
|
f = img.astype(float)
|
|
if cond == "night":
|
|
f = f * 0.8 + rng.normal(0, 14, f.shape)
|
|
elif cond == "fog":
|
|
f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)
|
|
elif cond == "rain":
|
|
f = f + rng.normal(0, 8, f.shape)
|
|
for _ in range(20):
|
|
x, y0 = rng.integers(0, W), rng.integers(0, H - 8)
|
|
f[y0:y0 + 8, x] += 45
|
|
else:
|
|
f = f + rng.normal(0, 6, f.shape)
|
|
return np.clip(f, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def make_sequence(seq_id, cond, rng, kind="mixed", n_frames=30, n_drone=None, n_bird=None):
|
|
"""시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation"""
|
|
objs = []
|
|
if kind == "swarm":
|
|
v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)
|
|
objs = [_new_obj("drone", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]
|
|
objs.append(_new_obj("bird", rng))
|
|
elif kind == "dive":
|
|
d = _new_obj("drone", rng, dive=True)
|
|
d["y"] = float(rng.uniform(4, 14))
|
|
objs = [d, _new_obj("bird", rng), _new_obj("clutter", rng)]
|
|
elif kind == "saturation":
|
|
objs = [_new_obj("drone", rng) for _ in range(n_drone)] + [_new_obj("bird", rng) for _ in range(n_bird)]
|
|
else:
|
|
objs = [_new_obj("drone", rng) for _ in range(int(rng.integers(1, 3)))]
|
|
objs += [_new_obj("bird", rng) for _ in range(int(rng.integers(0, 3)))]
|
|
if rng.random() < 0.5:
|
|
objs.append(_new_obj("civil", rng))
|
|
if rng.random() < 0.6:
|
|
objs.append(_new_obj("clutter", rng))
|
|
frames, labels = np.zeros((n_frames, H, W), np.uint8), []
|
|
base = rng.normal(40, 6, (H, W))
|
|
for t in range(n_frames):
|
|
canvas = base.copy()
|
|
for i, o in enumerate(objs):
|
|
if t > 0:
|
|
if o["cls"] == "bird":
|
|
o["vx"] = float(np.clip(o["vx"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
o["vy"] = float(np.clip(o["vy"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
if o["dive"] and t > 10:
|
|
o["vy"] += 0.18
|
|
o["x"] = float(np.clip(o["x"] + o["vx"], 1, W - o["size"] - 1))
|
|
o["y"] = float(np.clip(o["y"] + o["vy"], 1, H - o["size"] - 1))
|
|
heat = o["heat"]
|
|
if o["cls"] == "clutter" and rng.random() < 0.3:
|
|
heat *= 0.7
|
|
if o["cls"] == "bird":
|
|
heat *= float(rng.uniform(0.85, 1.1))
|
|
x, y, s = int(round(o["x"])), int(round(o["y"])), o["size"]
|
|
canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)
|
|
labels.append({"frame": t, "track": f"{seq_id}_T{i:02d}", "class": o["cls"], "bbox": [x, y, s, s],
|
|
"pattern": "dive" if o["dive"] else ("swarm" if kind == "swarm" and o["cls"] == "drone" else "normal")})
|
|
frames[t] = _noise(canvas, cond, rng)
|
|
return frames, labels
|
|
|
|
|
|
def make_dataset(n=24, seed=7):
|
|
rng = np.random.default_rng(seed)
|
|
conds = ["clear", "night", "fog", "rain"]
|
|
out = []
|
|
for i in range(n):
|
|
kind = "swarm" if i % 8 == 5 else ("dive" if i % 8 == 6 else "mixed")
|
|
f, lab = make_sequence(f"SEQ{i:03d}", conds[i % 4], rng, kind)
|
|
out.append({"id": f"SEQ{i:03d}", "cond": conds[i % 4], "kind": kind, "frames": f, "labels": lab})
|
|
return out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---- 전처리·탐지 공통 함수 ----
|
|
def preprocess(frame, cond):
|
|
f = frame.astype(float)
|
|
if cond == "rain": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)
|
|
f = ndimage.grey_opening(f, size=(1, 2))
|
|
lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장
|
|
return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)
|
|
|
|
|
|
def detect(frame, cond, k=2.0, min_area=3, max_area=60):
|
|
img = preprocess(frame, cond)
|
|
lab, _ = ndimage.label(img > img.mean() + k * img.std())
|
|
dets = []
|
|
for i, sl in enumerate(ndimage.find_objects(lab), start=1):
|
|
area = int((lab[sl] == i).sum())
|
|
if min_area <= area <= max_area:
|
|
y, x = sl[0].start, sl[1].start
|
|
dets.append({"bbox": [x, y, sl[1].stop - x, sl[0].stop - y], "area": area,
|
|
"thermal": float(frame[sl][lab[sl] == i].max())})
|
|
return dets
|
|
|
|
|
|
def iou(a, b):
|
|
ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
|
|
iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
|
|
inter = ix * iy
|
|
return inter / (a[2] * a[3] + b[2] * b[3] - inter)
|
|
|
|
# ---- 추적·특징·패턴 공통 함수 ----
|
|
def center(b):
|
|
return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])
|
|
|
|
|
|
def track(per_frame, gate=6.0, max_missed=2, min_len=5):
|
|
"""예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다."""
|
|
active, done = [], []
|
|
for t, dets in enumerate(per_frame):
|
|
used = set()
|
|
for tr in sorted(active, key=lambda x: -len(x["t"])):
|
|
last = center(tr["b"][-1])
|
|
vel = last - center(tr["b"][-2]) if len(tr["b"]) > 1 else np.zeros(2)
|
|
pred = last + vel * (t - tr["t"][-1])
|
|
cand = [(np.linalg.norm(center(d["bbox"]) - pred), j) for j, d in enumerate(dets) if j not in used]
|
|
dist, j = min(cand, default=(gate, -1))
|
|
if j >= 0 and dist < gate:
|
|
used.add(j)
|
|
tr["t"].append(t); tr["b"].append(dets[j]["bbox"])
|
|
tr["area"].append(dets[j]["area"]); tr["heat"].append(dets[j]["thermal"])
|
|
still = []
|
|
for tr in active:
|
|
(still if t - tr["t"][-1] <= max_missed else done).append(tr)
|
|
active = still
|
|
for j, d in enumerate(dets):
|
|
if j not in used:
|
|
active.append({"t": [t], "b": [d["bbox"]], "area": [d["area"]], "heat": [d["thermal"]]})
|
|
return [tr for tr in done + active if len(tr["t"]) >= min_len]
|
|
|
|
|
|
FEATURES = ["mean_speed", "speed_std", "turn", "straight", "vy_accel", "area", "heat", "heat_std"]
|
|
|
|
|
|
def features(tr):
|
|
c = np.array([center(b) for b in tr["b"]]); f = np.array(tr["t"], float)
|
|
v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]
|
|
sp = np.linalg.norm(v, axis=1)
|
|
k = 3 if len(c) > 4 else 1
|
|
v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]
|
|
hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2
|
|
turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]
|
|
half = len(v) // 2
|
|
return {"mean_speed": sp.mean(), "speed_std": sp.std(), "turn": turns.mean() if len(turns) else 0.0,
|
|
"straight": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),
|
|
"vy_accel": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,
|
|
"area": np.mean(tr["area"]), "heat": np.mean(tr["heat"]), "heat_std": np.std(tr["heat"]),
|
|
"vx": v[:, 0].mean(), "vy": v[:, 1].mean(), "x": c[-1, 0], "y": c[-1, 1]}
|
|
|
|
|
|
def pattern(f):
|
|
if f["mean_speed"] < 0.25:
|
|
return "static"
|
|
if f["vy_accel"] > 0.06:
|
|
return "dive"
|
|
if f["turn"] > 0.4 or f["straight"] < 0.6:
|
|
return "erratic"
|
|
return "steady"
|
|
|
|
|
|
def swarms(trs, vel=0.2, dist=18.0, min_n=3):
|
|
"""속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다."""
|
|
cand = [i for i, t in enumerate(trs) if t["pattern"] != "static"]
|
|
adj = {i: set() for i in cand}
|
|
for a in cand:
|
|
for b in cand:
|
|
fa, fb = trs[a]["f"], trs[b]["f"]
|
|
if a < b and np.hypot(fa["vx"] - fb["vx"], fa["vy"] - fb["vy"]) < vel and np.hypot(fa["x"] - fb["x"], fa["y"] - fb["y"]) < dist:
|
|
adj[a].add(b); adj[b].add(a)
|
|
seen, groups = set(), []
|
|
for i in cand:
|
|
if i in seen:
|
|
continue
|
|
stack, comp = [i], []
|
|
while stack:
|
|
k = stack.pop()
|
|
if k not in seen:
|
|
seen.add(k); comp.append(k); stack += list(adj[k] - seen)
|
|
if len(comp) >= min_n:
|
|
groups.append(comp)
|
|
return groups
|
|
|
|
|
|
def analyze(s, k=2.0):
|
|
"""시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용)."""
|
|
per = [detect(fr, s["cond"], k) if fr is not None else [] for fr in s["frames"]]
|
|
trs = track(per)
|
|
for tr in trs:
|
|
tr["f"] = features(tr); tr["pattern"] = pattern(tr["f"]); tr["swarm"] = None; tr["seq"] = s["id"]
|
|
votes = {}
|
|
for t, b in zip(tr["t"], tr["b"]):
|
|
best = max(((iou(b, l["bbox"]), l) for l in s["labels"] if l["frame"] == t), default=(0, None), key=lambda x: x[0])
|
|
key = (best[1]["track"], best[1]["class"], best[1]["pattern"]) if best[0] > 0.05 else ("none", "noise", "normal")
|
|
votes[key] = votes.get(key, 0) + 1
|
|
tr["gt_track"], tr["gt_class"], tr["gt_pattern"] = max(votes, key=votes.get)
|
|
for g, comp in enumerate(swarms(trs)):
|
|
for i in comp:
|
|
trs[i]["swarm"] = g
|
|
if trs[i]["pattern"] in ("steady", "erratic"):
|
|
trs[i]["pattern"] = "swarm"
|
|
return trs
|
|
|
|
|
|
def X_of(trs):
|
|
return np.array([[t["f"][k] for k in FEATURES] for t in trs])
|
|
|
|
|
|
def run_system(k, train_seed=7, test_seed=11):
|
|
"""탐지 임계값 k로 학습·검증 데이터를 처리하고, 검증 궤적마다 드론 확률(prob)을 붙인다."""
|
|
tr = [t for s in make_dataset(24, train_seed) for t in analyze(s, k)]
|
|
te_data = make_dataset(24, test_seed)
|
|
te = [t for s in te_data for t in analyze(s, k)]
|
|
m = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=1000, random_state=0))
|
|
m.fit(X_of(tr), [t["gt_class"] == "drone" for t in tr])
|
|
for t, p in zip(te, m.predict_proba(X_of(te))[:, 1]):
|
|
t["prob"] = p
|
|
t["cond"] = next(s["cond"] for s in te_data if s["id"] == t["seq"])
|
|
drones = {l["track"] for s in te_data for l in s["labels"] if l["class"] == "drone"}
|
|
return te, drones
|
|
|
|
|
|
def set_alerts(te, thr):
|
|
for t in te:
|
|
t["alert"] = t["pattern"] != "static" and t["prob"] >= thr
|
|
return te
|
|
|
|
|
|
def summarize(te, drones):
|
|
alerts = [t for t in te if t["alert"]]
|
|
tp = {t["gt_track"] for t in alerts if t["gt_class"] == "drone"}
|
|
far = sum(t["gt_class"] != "drone" for t in alerts) / max(len(alerts), 1)
|
|
rec = len(tp) / len(drones)
|
|
acc = 2 * (1 - far) * rec / max((1 - far) + rec, 1e-9)
|
|
return {"경보": len(alerts), "오경보율": round(far, 3), "재현율": round(rec, 3), "종합정확도(F1)": round(acc, 3)}
|
|
|
|
|
|
def aar_log(te, drones):
|
|
"""AAR 일지: 오경보는 실제 정체·기상별로, 미탐은 원인별로 분류."""
|
|
rows = []
|
|
for t in te:
|
|
if t["alert"] and t["gt_class"] != "drone":
|
|
rows.append({"구분": "오경보", "원인": f"{t['gt_class']} 오인", "기상": t["cond"], "궤적": t["seq"]})
|
|
tracked = {t["gt_track"] for t in te if t["gt_class"] == "drone"}
|
|
alerted = {t["gt_track"] for t in te if t["gt_class"] == "drone" and t["alert"]}
|
|
for d in sorted(drones):
|
|
if d not in tracked:
|
|
rows.append({"구분": "미탐", "원인": "추적 실패(탐지 누락)", "기상": "-", "궤적": d})
|
|
elif d not in alerted:
|
|
rows.append({"구분": "미탐", "원인": "신뢰도 점수 미달", "기상": "-", "궤적": d})
|
|
return pd.DataFrame(rows, columns=["구분", "원인", "기상", "궤적"])
|
|
|
|
|
|
# 1) 초기 설정(k=2.0, 경보 임계값 0.3: '놓치지 않기'를 우선한 설정)으로 모의 운용
|
|
before_te, drones = run_system(2.0)
|
|
set_alerts(before_te, 0.3)
|
|
before = summarize(before_te, drones)
|
|
log = aar_log(before_te, drones)
|
|
print("[1. 무엇이 일어났나] 초기 설정 결과:", before)
|
|
print(log.groupby(["구분", "원인"]).size().rename("건수").to_string())
|
|
print("\n[2. 왜 일어났나] 오경보 기상 분포:", log[log["구분"] == "오경보"]["기상"].value_counts().to_dict())
|
|
|
|
# 2) 재보정: 제약(오경보율 15% 이하, 재현율 90% 이상) 안에서 F1 최대 조합 탐색
|
|
grid = []
|
|
for k in [2.0, 2.5, 3.0]:
|
|
te, dr = run_system(k)
|
|
for thr in [0.3, 0.4, 0.5, 0.6, 0.7]:
|
|
grid.append({"k": k, "경보임계값": thr, **summarize(set_alerts(te, thr), dr)})
|
|
g = pd.DataFrame(grid)
|
|
ok = g[(g["오경보율"] <= 0.15) & (g["재현율"] >= 0.9)].sort_values(["종합정확도(F1)", "재현율"], ascending=False)
|
|
best = ok.iloc[0]
|
|
print("\n[3. 무엇을 바꾸나] 격자 탐색 상위 5개")
|
|
print(ok.head().to_string(index=False))
|
|
after_te, _ = run_system(best["k"])
|
|
set_alerts(after_te, best["경보임계값"])
|
|
after = summarize(after_te, drones)
|
|
after_log = aar_log(after_te, drones)
|
|
print(f"\n재보정값: k={best['k']}, 경보 임계값={best['경보임계값']}")
|
|
print("재보정 후:", after)
|
|
print(f"종합정확도 변화: {before['종합정확도(F1)']:.3f} -> {after['종합정확도(F1)']:.3f} ({(after['종합정확도(F1)'] - before['종합정확도(F1)']) * 100:+.1f}%p)")
|
|
repeat = set(log[log["구분"] == "오경보"]["궤적"]) & set(after_log[after_log["구분"] == "오경보"]["궤적"])
|
|
print(f"재보정 후에도 반복된 오경보 시퀀스: {sorted(repeat) if repeat else '없음'}")
|
|
|
|
tuned = f"detection_k: {best['k']}\nalert_threshold: {best['경보임계값']}\nconstraints: {{max_far: 0.15, min_recall: 0.9}}"
|
|
print("\n[재보정 파라미터(ops01_tuned_params.yaml 내용)]\n" + tuned)
|
|
|
|
plt.figure(figsize=(6, 4))
|
|
for k in sorted(g["k"].unique()):
|
|
s = g[g["k"] == k]
|
|
plt.plot(s["오경보율"], s["재현율"], "o-", label=f"k={k}")
|
|
plt.axvline(0.15, ls="--", c="gray"); plt.axhline(0.9, ls="--", c="gray")
|
|
plt.xlabel("false alarm rate"); plt.ylabel("drone recall"); plt.legend(); plt.title("AAR re-tuning grid (synthetic)")
|
|
plt.show()
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
[1. 무엇이 일어났나] 초기 설정 결과: {'경보': 49, '오경보율': 0.102, '재현율': 1.0, '종합정확도(F1)': 0.946}
|
|
구분 원인
|
|
오경보 bird 오인 4
|
|
civil 오인 1
|
|
|
|
[2. 왜 일어났나] 오경보 기상 분포: {'clear': 2, 'night': 2, 'fog': 1}
|
|
|
|
[3. 무엇을 바꾸나] 격자 탐색 상위 5개
|
|
k 경보임계값 경보 오경보율 재현율 종합정확도(F1)
|
|
2.5 0.5 44 0.023 0.977 0.977
|
|
2.5 0.6 44 0.023 0.977 0.977
|
|
2.0 0.5 45 0.044 0.977 0.966
|
|
2.5 0.4 45 0.044 0.977 0.966
|
|
2.0 0.4 48 0.083 1.000 0.957
|
|
|
|
재보정값: k=2.5, 경보 임계값=0.5
|
|
재보정 후: {'경보': 44, '오경보율': 0.023, '재현율': 0.977, '종합정확도(F1)': 0.977}
|
|
종합정확도 변화: 0.946 -> 0.977 (+3.1%p)
|
|
재보정 후에도 반복된 오경보 시퀀스: ['SEQ022']
|
|
|
|
[재보정 파라미터(ops01_tuned_params.yaml 내용)]
|
|
detection_k: 2.5
|
|
alert_threshold: 0.5
|
|
constraints: {max_far: 0.15, min_recall: 0.9}
|
|
```
|
|
|
|
## 미션 10. 드론 공중위협 객체 탐지 최종 파이프라인 통합 및 대드론 방어 브리핑 패키지 완성
|
|
|
|
- 난이도: 최고급 / 예상 소요시간: 3.5시간
|
|
- 코드 설명: 데이터 생성·검증부터 전처리, 탐지, 추적·패턴, 신뢰도 점수, HITL 승인 대기열, 상황판, AAR 재보정 파라미터 적용까지 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검 결과와 지휘관 브리핑 요약을 출력한다.
|
|
|
|
```python
|
|
"""데이터 생성·검증부터 전처리, 탐지, 추적·패턴, 신뢰도 점수, HITL 승인 대기열, 상황판, AAR 재보정 파라미터 적용까지 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검 결과와 지휘관 브리핑 요약을 출력한다."""
|
|
import hashlib
|
|
import json
|
|
import time
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
from scipy import ndimage
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.pipeline import make_pipeline
|
|
from sklearn.preprocessing import StandardScaler
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [교육용가상자료] 합성 적외선 영상 생성기 (실제 영상·부대 정보 없음)
|
|
# 64x64 프레임에 드론·조류·민간기·지상 반사체(클러터)를 그리고 정답 라벨을 함께 만든다.
|
|
# ---------------------------------------------------------------------------
|
|
H = W = 64
|
|
PROFILE = {"drone": ((2, 3), (170, 225)), "bird": ((2, 3), (110, 150)),
|
|
"civil": ((4, 6), (190, 240)), "clutter": ((2, 4), (140, 190))}
|
|
|
|
|
|
def _new_obj(cls, rng, dive=False, v=None, xy=None):
|
|
(s0, s1), (h0, h1) = PROFILE[cls]
|
|
x, y = xy if xy else (rng.uniform(6, W - 12), rng.uniform(6, H - 12))
|
|
speed = {"drone": 1.3, "bird": 0.9, "civil": 0.35, "clutter": 0.0}[cls]
|
|
vx, vy = v if v is not None else rng.uniform(-speed, speed, 2)
|
|
return {"cls": cls, "size": int(rng.integers(s0, s1 + 1)), "heat": float(rng.uniform(h0, h1)),
|
|
"x": float(x), "y": float(y), "vx": float(vx), "vy": float(vy), "dive": dive}
|
|
|
|
|
|
def _noise(img, cond, rng):
|
|
f = img.astype(float)
|
|
if cond == "night":
|
|
f = f * 0.8 + rng.normal(0, 14, f.shape)
|
|
elif cond == "fog":
|
|
f = f * 0.55 + 55 + rng.normal(0, 9, f.shape)
|
|
elif cond == "rain":
|
|
f = f + rng.normal(0, 8, f.shape)
|
|
for _ in range(20):
|
|
x, y0 = rng.integers(0, W), rng.integers(0, H - 8)
|
|
f[y0:y0 + 8, x] += 45
|
|
else:
|
|
f = f + rng.normal(0, 6, f.shape)
|
|
return np.clip(f, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def make_sequence(seq_id, cond, rng, kind="mixed", n_frames=30, n_drone=None, n_bird=None):
|
|
"""시퀀스 1개 = (프레임 배열, 라벨 목록). kind: mixed / swarm / dive / saturation"""
|
|
objs = []
|
|
if kind == "swarm":
|
|
v, cx, cy = rng.uniform(-1, 1, 2), rng.uniform(15, 35), rng.uniform(15, 35)
|
|
objs = [_new_obj("drone", rng, v=v, xy=(cx + i * 5, cy + (i % 2) * 5)) for i in range(4)]
|
|
objs.append(_new_obj("bird", rng))
|
|
elif kind == "dive":
|
|
d = _new_obj("drone", rng, dive=True)
|
|
d["y"] = float(rng.uniform(4, 14))
|
|
objs = [d, _new_obj("bird", rng), _new_obj("clutter", rng)]
|
|
elif kind == "saturation":
|
|
objs = [_new_obj("drone", rng) for _ in range(n_drone)] + [_new_obj("bird", rng) for _ in range(n_bird)]
|
|
else:
|
|
objs = [_new_obj("drone", rng) for _ in range(int(rng.integers(1, 3)))]
|
|
objs += [_new_obj("bird", rng) for _ in range(int(rng.integers(0, 3)))]
|
|
if rng.random() < 0.5:
|
|
objs.append(_new_obj("civil", rng))
|
|
if rng.random() < 0.6:
|
|
objs.append(_new_obj("clutter", rng))
|
|
frames, labels = np.zeros((n_frames, H, W), np.uint8), []
|
|
base = rng.normal(40, 6, (H, W))
|
|
for t in range(n_frames):
|
|
canvas = base.copy()
|
|
for i, o in enumerate(objs):
|
|
if t > 0:
|
|
if o["cls"] == "bird":
|
|
o["vx"] = float(np.clip(o["vx"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
o["vy"] = float(np.clip(o["vy"] + rng.normal(0, 0.45), -1.6, 1.6))
|
|
if o["dive"] and t > 10:
|
|
o["vy"] += 0.18
|
|
o["x"] = float(np.clip(o["x"] + o["vx"], 1, W - o["size"] - 1))
|
|
o["y"] = float(np.clip(o["y"] + o["vy"], 1, H - o["size"] - 1))
|
|
heat = o["heat"]
|
|
if o["cls"] == "clutter" and rng.random() < 0.3:
|
|
heat *= 0.7
|
|
if o["cls"] == "bird":
|
|
heat *= float(rng.uniform(0.85, 1.1))
|
|
x, y, s = int(round(o["x"])), int(round(o["y"])), o["size"]
|
|
canvas[y:y + s, x:x + s] = np.maximum(canvas[y:y + s, x:x + s], heat)
|
|
labels.append({"frame": t, "track": f"{seq_id}_T{i:02d}", "class": o["cls"], "bbox": [x, y, s, s],
|
|
"pattern": "dive" if o["dive"] else ("swarm" if kind == "swarm" and o["cls"] == "drone" else "normal")})
|
|
frames[t] = _noise(canvas, cond, rng)
|
|
return frames, labels
|
|
|
|
|
|
def make_dataset(n=24, seed=7):
|
|
rng = np.random.default_rng(seed)
|
|
conds = ["clear", "night", "fog", "rain"]
|
|
out = []
|
|
for i in range(n):
|
|
kind = "swarm" if i % 8 == 5 else ("dive" if i % 8 == 6 else "mixed")
|
|
f, lab = make_sequence(f"SEQ{i:03d}", conds[i % 4], rng, kind)
|
|
out.append({"id": f"SEQ{i:03d}", "cond": conds[i % 4], "kind": kind, "frames": f, "labels": lab})
|
|
return out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---- 전처리·탐지 공통 함수 ----
|
|
def preprocess(frame, cond):
|
|
f = frame.astype(float)
|
|
if cond == "rain": # 폭 1px 세로 빗줄기 제거(가로 방향 형태학적 열림)
|
|
f = ndimage.grey_opening(f, size=(1, 2))
|
|
lo, hi = np.percentile(f, [1, 99.5]) # 대비 확장
|
|
return np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)
|
|
|
|
|
|
def detect(frame, cond, k=2.0, min_area=3, max_area=60):
|
|
img = preprocess(frame, cond)
|
|
lab, _ = ndimage.label(img > img.mean() + k * img.std())
|
|
dets = []
|
|
for i, sl in enumerate(ndimage.find_objects(lab), start=1):
|
|
area = int((lab[sl] == i).sum())
|
|
if min_area <= area <= max_area:
|
|
y, x = sl[0].start, sl[1].start
|
|
dets.append({"bbox": [x, y, sl[1].stop - x, sl[0].stop - y], "area": area,
|
|
"thermal": float(frame[sl][lab[sl] == i].max())})
|
|
return dets
|
|
|
|
|
|
def iou(a, b):
|
|
ix = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
|
|
iy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
|
|
inter = ix * iy
|
|
return inter / (a[2] * a[3] + b[2] * b[3] - inter)
|
|
|
|
# ---- 추적·특징·패턴 공통 함수 ----
|
|
def center(b):
|
|
return np.array([b[0] + b[2] / 2, b[1] + b[3] / 2])
|
|
|
|
|
|
def track(per_frame, gate=6.0, max_missed=2, min_len=5):
|
|
"""예측 위치(마지막 위치 + 속도)와 가장 가까운 탐지를 이어 궤적을 만든다."""
|
|
active, done = [], []
|
|
for t, dets in enumerate(per_frame):
|
|
used = set()
|
|
for tr in sorted(active, key=lambda x: -len(x["t"])):
|
|
last = center(tr["b"][-1])
|
|
vel = last - center(tr["b"][-2]) if len(tr["b"]) > 1 else np.zeros(2)
|
|
pred = last + vel * (t - tr["t"][-1])
|
|
cand = [(np.linalg.norm(center(d["bbox"]) - pred), j) for j, d in enumerate(dets) if j not in used]
|
|
dist, j = min(cand, default=(gate, -1))
|
|
if j >= 0 and dist < gate:
|
|
used.add(j)
|
|
tr["t"].append(t); tr["b"].append(dets[j]["bbox"])
|
|
tr["area"].append(dets[j]["area"]); tr["heat"].append(dets[j]["thermal"])
|
|
still = []
|
|
for tr in active:
|
|
(still if t - tr["t"][-1] <= max_missed else done).append(tr)
|
|
active = still
|
|
for j, d in enumerate(dets):
|
|
if j not in used:
|
|
active.append({"t": [t], "b": [d["bbox"]], "area": [d["area"]], "heat": [d["thermal"]]})
|
|
return [tr for tr in done + active if len(tr["t"]) >= min_len]
|
|
|
|
|
|
FEATURES = ["mean_speed", "speed_std", "turn", "straight", "vy_accel", "area", "heat", "heat_std"]
|
|
|
|
|
|
def features(tr):
|
|
c = np.array([center(b) for b in tr["b"]]); f = np.array(tr["t"], float)
|
|
v = np.diff(c, axis=0) / np.maximum(np.diff(f), 1)[:, None]
|
|
sp = np.linalg.norm(v, axis=1)
|
|
k = 3 if len(c) > 4 else 1
|
|
v3 = (c[k:] - c[:-k]) / np.maximum(f[k:] - f[:-k], 1)[:, None]
|
|
hd = np.arctan2(v3[:, 1], v3[:, 0]); mv = np.linalg.norm(v3, axis=1) > 0.2
|
|
turns = np.abs(np.angle(np.exp(1j * np.diff(hd))))[mv[1:] & mv[:-1]]
|
|
half = len(v) // 2
|
|
return {"mean_speed": sp.mean(), "speed_std": sp.std(), "turn": turns.mean() if len(turns) else 0.0,
|
|
"straight": np.linalg.norm(c[-1] - c[0]) / max(sp.sum(), 1e-6),
|
|
"vy_accel": (v[half:, 1].mean() - v[:half, 1].mean()) / max(1, (f[-1] - f[0]) / 2) if half else 0.0,
|
|
"area": np.mean(tr["area"]), "heat": np.mean(tr["heat"]), "heat_std": np.std(tr["heat"]),
|
|
"vx": v[:, 0].mean(), "vy": v[:, 1].mean(), "x": c[-1, 0], "y": c[-1, 1]}
|
|
|
|
|
|
def pattern(f):
|
|
if f["mean_speed"] < 0.25:
|
|
return "static"
|
|
if f["vy_accel"] > 0.06:
|
|
return "dive"
|
|
if f["turn"] > 0.4 or f["straight"] < 0.6:
|
|
return "erratic"
|
|
return "steady"
|
|
|
|
|
|
def swarms(trs, vel=0.2, dist=18.0, min_n=3):
|
|
"""속도가 비슷하고 가까이 움직이는 궤적 3개 이상을 군집으로 묶는다."""
|
|
cand = [i for i, t in enumerate(trs) if t["pattern"] != "static"]
|
|
adj = {i: set() for i in cand}
|
|
for a in cand:
|
|
for b in cand:
|
|
fa, fb = trs[a]["f"], trs[b]["f"]
|
|
if a < b and np.hypot(fa["vx"] - fb["vx"], fa["vy"] - fb["vy"]) < vel and np.hypot(fa["x"] - fb["x"], fa["y"] - fb["y"]) < dist:
|
|
adj[a].add(b); adj[b].add(a)
|
|
seen, groups = set(), []
|
|
for i in cand:
|
|
if i in seen:
|
|
continue
|
|
stack, comp = [i], []
|
|
while stack:
|
|
k = stack.pop()
|
|
if k not in seen:
|
|
seen.add(k); comp.append(k); stack += list(adj[k] - seen)
|
|
if len(comp) >= min_n:
|
|
groups.append(comp)
|
|
return groups
|
|
|
|
|
|
def analyze(s, k=2.0):
|
|
"""시퀀스 1개: 탐지 -> 추적 -> 특징 -> 패턴 -> 군집 -> 정답 라벨 연결(평가용)."""
|
|
per = [detect(fr, s["cond"], k) if fr is not None else [] for fr in s["frames"]]
|
|
trs = track(per)
|
|
for tr in trs:
|
|
tr["f"] = features(tr); tr["pattern"] = pattern(tr["f"]); tr["swarm"] = None; tr["seq"] = s["id"]
|
|
votes = {}
|
|
for t, b in zip(tr["t"], tr["b"]):
|
|
best = max(((iou(b, l["bbox"]), l) for l in s["labels"] if l["frame"] == t), default=(0, None), key=lambda x: x[0])
|
|
key = (best[1]["track"], best[1]["class"], best[1]["pattern"]) if best[0] > 0.05 else ("none", "noise", "normal")
|
|
votes[key] = votes.get(key, 0) + 1
|
|
tr["gt_track"], tr["gt_class"], tr["gt_pattern"] = max(votes, key=votes.get)
|
|
for g, comp in enumerate(swarms(trs)):
|
|
for i in comp:
|
|
trs[i]["swarm"] = g
|
|
if trs[i]["pattern"] in ("steady", "erratic"):
|
|
trs[i]["pattern"] = "swarm"
|
|
return trs
|
|
|
|
CONFIG = {"detection_k": 2.5, "alert_threshold": 0.5, "queue_capacity": 30} # 미션 9 재보정값
|
|
|
|
|
|
def validate(seq):
|
|
errs = []
|
|
if seq["cond"] not in ("clear", "night", "fog", "rain"):
|
|
errs.append("condition 허용값 아님")
|
|
if seq["frames"].ndim != 3 or seq["frames"].shape[1:] != (64, 64):
|
|
errs.append("해상도 오류")
|
|
for l in seq["labels"]:
|
|
if l["class"] not in PROFILE or len(l["bbox"]) != 4:
|
|
errs.append("라벨 오류"); break
|
|
return errs
|
|
|
|
|
|
def X_of(trs):
|
|
return np.array([[t["f"][k] for k in FEATURES] for t in trs])
|
|
|
|
|
|
class Queue:
|
|
def __init__(self):
|
|
self.items, self.log = [], []
|
|
|
|
def submit(self, t):
|
|
prev = self.log[-1]["hash"] if self.log else "GENESIS"
|
|
body = {"event": "SUBMIT", "id": f"{t['seq']}-{len(self.items)}", "score": round(t["score"], 3)}
|
|
self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})
|
|
self.items.append({**body, "grade": t["grade"], "status": "PENDING"})
|
|
|
|
|
|
# ---------------- 1. 학습(엔진 준비) ----------------
|
|
t_start = time.perf_counter()
|
|
train = [t for s in make_dataset(24, 7) for t in analyze(s, CONFIG["detection_k"])]
|
|
engine = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=1000, random_state=0))
|
|
engine.fit(X_of(train), [t["gt_class"] == "drone" for t in train])
|
|
|
|
# ---------------- 2. 운용 데이터 처리 ----------------
|
|
ops = make_dataset(24, 11)
|
|
ops[3]["cond"] = "snow" # 손상 데이터 1건 주입(검증 단계에서 걸러져야 함)
|
|
queue, rejected, all_tracks, runs = Queue(), {}, [], []
|
|
for s in ops:
|
|
errs = validate(s)
|
|
if errs:
|
|
rejected[s["id"]] = errs; continue
|
|
t0 = time.perf_counter()
|
|
trs = analyze(s, CONFIG["detection_k"])
|
|
if trs:
|
|
for t, p in zip(trs, engine.predict_proba(X_of(trs))[:, 1]):
|
|
t["score"] = 0.0 if t["pattern"] == "static" else float(p)
|
|
t["grade"] = "High" if t["score"] >= 0.8 else ("Medium" if t["score"] >= 0.5 else "Low")
|
|
t["alert"] = t["pattern"] != "static" and t["score"] >= CONFIG["alert_threshold"]
|
|
if t["alert"]:
|
|
queue.submit(t)
|
|
runs.append({"seq": s["id"], "ms_per_frame": (time.perf_counter() - t0) * 1000 / len(s["frames"]),
|
|
"alerts": sum(t["alert"] for t in trs)})
|
|
all_tracks += trs
|
|
|
|
# ---------------- 3. 성능 평가 ----------------
|
|
drones = {l["track"] for s in ops if s["id"] not in rejected for l in s["labels"] if l["class"] == "drone"}
|
|
alerts = [t for t in all_tracks if t["alert"]]
|
|
far = sum(t["gt_class"] != "drone" for t in alerts) / max(len(alerts), 1)
|
|
recall = len({t["gt_track"] for t in alerts if t["gt_class"] == "drone"}) / len(drones)
|
|
ms = np.mean([r["ms_per_frame"] for r in runs])
|
|
|
|
# ---------------- 4. 자체 점검(통합 테스트) ----------------
|
|
chk = {
|
|
"손상 데이터 차단": "SEQ003" in rejected,
|
|
"처리 시퀀스 23개": len(runs) == 23,
|
|
"드론 재현율 90% 이상": recall >= 0.9,
|
|
"오경보율 15% 이하": far <= 0.15,
|
|
"자동 승인 0건": all(i["status"] == "PENDING" for i in queue.items),
|
|
"경보 전량 대기열 등록": len(queue.items) == len(alerts),
|
|
"감사 로그 건수 일치": len(queue.log) == len(queue.items),
|
|
"프레임당 50ms 이내": ms < 50,
|
|
"등급 규칙 일관": all((t["grade"] == "High") == (t["score"] >= 0.8) for t in all_tracks),
|
|
"static 궤적 경보 없음": not any(t["alert"] and t["pattern"] == "static" for t in all_tracks),
|
|
}
|
|
passed = sum(chk.values())
|
|
for k, v in chk.items():
|
|
print(f"[{'PASS' if v else 'FAIL'}] {k}")
|
|
print(f"자체 점검 {passed}/{len(chk)} 통과 ({passed / len(chk):.0%}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}")
|
|
|
|
# ---------------- 5. 상황판 ----------------
|
|
focus = max({t["seq"] for t in all_tracks}, key=lambda q: sum(t["alert"] for t in all_tracks if t["seq"] == q))
|
|
col = {"High": "tab:red", "Medium": "tab:orange", "Low": "tab:gray"}
|
|
fig, ax = plt.subplots(2, 2, figsize=(12, 8))
|
|
s = next(x for x in ops if x["id"] == focus)
|
|
ax[0, 0].imshow(s["frames"][-1], cmap="gray")
|
|
for t in [t for t in all_tracks if t["seq"] == focus]:
|
|
c = np.array([center(b) for b in t["b"]]); ax[0, 0].plot(c[:, 0], c[:, 1], color=col[t["grade"]], lw=2)
|
|
ax[0, 0].set_title(f"{focus} tracks (color = grade)"); ax[0, 0].axis("off")
|
|
g = pd.Series([t["grade"] for t in all_tracks]).value_counts().reindex(["High", "Medium", "Low"]).fillna(0)
|
|
ax[0, 1].bar(g.index, g.values, color=[col[i] for i in g.index]); ax[0, 1].set_title("Tracks by grade")
|
|
ax[1, 0].bar([r["seq"][-2:] for r in runs], [r["alerts"] for r in runs]); ax[1, 0].set_title("Alerts per sequence (pending human review)")
|
|
ax[1, 1].axis("off")
|
|
ax[1, 1].text(0, 0.9, f"Drone recall {recall:.1%}\nFalse alarm {far:.1%}\nQueue {len(queue.items)} pending\n"
|
|
f"Latency {ms:.2f} ms/frame\nSelf-check {passed}/{len(chk)}", family="monospace", fontsize=13, va="top")
|
|
plt.suptitle("OPS01 integrated pipeline - training data only"); plt.tight_layout(); plt.show()
|
|
|
|
# ---------------- 6. 지휘관 브리핑 요약 ----------------
|
|
print(f"""
|
|
[대드론 방어 브리핑 요약 - 교육용가상자료]
|
|
1. 처리: 시퀀스 {len(ops)}개 중 {len(runs)}개 처리, 손상 데이터 {len(rejected)}건 차단({', '.join(rejected)})
|
|
2. 성능: 드론 재현율 {recall:.1%}, 오경보율 {far:.1%}, 프레임당 {ms:.2f} ms, 전체 소요 {time.perf_counter() - t_start:.1f} s
|
|
3. 경보: {len(queue.items)}건 운용자 검토 대기 (High {sum(i['grade'] == 'High' for i in queue.items)} / Medium {sum(i['grade'] == 'Medium' for i in queue.items)}), 자동 승인 0건
|
|
4. 운용 유의: 경보는 운용자 승인 후에만 상급 지휘계통으로 전파하며, 교전 여부는 교전수칙·지휘계통에 따른다.
|
|
5. 한계: 합성 64x64 영상 기준 결과로, 실제 센서·기상·배경에서는 재학습과 재검증이 필요하다.""")
|
|
```
|
|
|
|
실행 결과 예시
|
|
|
|
```text
|
|
[PASS] 손상 데이터 차단
|
|
[PASS] 처리 시퀀스 23개
|
|
[PASS] 드론 재현율 90% 이상
|
|
[PASS] 오경보율 15% 이하
|
|
[PASS] 자동 승인 0건
|
|
[PASS] 경보 전량 대기열 등록
|
|
[PASS] 감사 로그 건수 일치
|
|
[PASS] 프레임당 50ms 이내
|
|
[PASS] 등급 규칙 일관
|
|
[PASS] static 궤적 경보 없음
|
|
자체 점검 10/10 통과 (100%), 파이프라인 실행 성공 예
|
|
|
|
[대드론 방어 브리핑 요약 - 교육용가상자료]
|
|
1. 처리: 시퀀스 24개 중 23개 처리, 손상 데이터 1건 차단(SEQ003)
|
|
2. 성능: 드론 재현율 97.6%, 오경보율 2.4%, 프레임당 0.67 ms, 전체 소요 1.5 s
|
|
3. 경보: 42건 운용자 검토 대기 (High 32 / Medium 10), 자동 승인 0건
|
|
4. 운용 유의: 경보는 운용자 승인 후에만 상급 지휘계통으로 전파하며, 교전 여부는 교전수칙·지휘계통에 따른다.
|
|
5. 한계: 합성 64x64 영상 기준 결과로, 실제 센서·기상·배경에서는 재학습과 재검증이 필요하다.
|
|
```
|