Files
2026-10-08 16:43:48 +09:00

3344 lines
216 KiB
Markdown

# QLT03 정답 코드 - 군수품 제원정보 이상치 자동검증 Tool
> [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다.
## 실행 환경
- Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib
- 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다.
- 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다.
- 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다.
## 미션 1. 장비 외관 결함(균열, 부식, 파손, 누유) 고해상도 사진 데이터셋 및 라벨링 정의
- 난이도: 초급 / 예상 소요시간: 1.5시간
- 코드 설명: 장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋의 라벨링 기준, 사진·장비 제원(관리 기록)·결함 라벨·판정 결과의 스키마와 데이터 딕셔너리(피처 20개 이상), 업무 프로세스 맵을 정의하고 가상 샘플로 제원 정합성을 검증한다.
```python
"""장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋의 라벨링 기준, 사진·장비 제원(관리 기록)·결함 라벨·판정 결과의 스키마와 데이터 딕셔너리(피처 20개 이상), 업무 프로세스 맵을 정의하고 가상 샘플로 제원 정합성을 검증한다."""
import json
import re
import numpy as np
import pandas as pd
# 1) 업무 프로세스 맵: 촬영에서 정비 등급 승인·제원 환류까지
PROCESS = [("촬영", "사진 ID, 촬영일, 촬영 조건(조명·오염)"), ("제원 대조", "장비 식별코드, 제조연도, 가동시간, 기록상 정비 등급"),
("전처리", "조명 보정, 해상도 표준화, 결함 영역(바운딩 박스)"), ("결함 판별", "결함 유형, 결함 면적, 추정 깊이"),
("손상도 산정", "손상도 점수(0~100), 판정 신뢰도"), ("검사관 보정", "수동 보정 여부, 보정 사유"),
("정비 등급 승인", "현장·야전·창정비 등급, 승인자, 승인 시각"), ("제원 환류", "판정 결과와 기록상 등급의 불일치, 제원 수정 요청"), ("사후검토(AAR)", "오판 원인, 파라미터 재보정 이력")]
# 2) 라벨링 기준(데이터 딕셔너리의 일부)
LABEL_RULES = {"crack": "선형 어두운 균열, 길이 10px 이상", "corrosion": "붉은 갈색 면, 면적 12px 이상", "damage": "함몰·파단 윤곽(밝은 테두리 포함)",
"leak": "어둡고 번지는 액체 자국(아래 방향 흐름)", "none": "결함 없음(무해한 얼룩·긁힘은 결함으로 라벨하지 않음)"}
# 3) 스키마: 그룹 / 필드 / 자료형 / 규칙 / 설명
S = {
"photo": {
"img_id": (str, {"pattern": r"^IMG\d{6}$"}, "사진 ID"),
"shot_day": (int, {"min": 0, "max": 400}, "촬영일(점검 시작 후 경과일)"),
"width": (int, {"enum": [64]}, "표준 해상도 가로(px)"),
"height": (int, {"enum": [64]}, "표준 해상도 세로(px)"),
"channels": (int, {"enum": [3]}, "채널 수(RGB)"),
"brightness": (float, {"min": 0.05, "max": 1.0}, "평균 밝기(0~1)"),
"mud_ratio": (float, {"min": 0, "max": 1}, "진흙·오염 가림 비율(추정)"),
},
"spec": {
"equip_id": (str, {"pattern": r"^VX-\d{4}-\d{4}$"}, "장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)"),
"equip_type": (str, {"enum": ["vehicle", "generator", "comm", "trailer"]}, "장비 구분"),
"mfg_year": (int, {"min": 1990, "max": 2026}, "제조연도(가상)"),
"op_hours": (float, {"min": 0, "max": 30000}, "누적 가동시간(h)"),
"env": (str, {"enum": ["coastal", "inland"]}, "운용 환경"),
"reg_grade": (int, {"enum": [1, 2, 3]}, "관리 기록상 직전 정비 등급(1 현장, 2 야전, 3 창정비)"),
},
"label": {
"defect_class": (str, {"enum": ["none", "crack", "corrosion", "damage", "leak"]}, "결함 유형"),
"bbox_x": (int, {"min": 0, "max": 63}, "결함 박스 좌상단 x"),
"bbox_y": (int, {"min": 0, "max": 63}, "결함 박스 좌상단 y"),
"bbox_w": (int, {"min": 1, "max": 64}, "결함 박스 폭"),
"bbox_h": (int, {"min": 1, "max": 64}, "결함 박스 높이"),
"emergency": (bool, {}, "긴급 점검 요청 여부(화질 불량이어도 보존)"),
},
"result": {
"area_frac": (float, {"min": 0, "max": 1}, "결함 면적 비율 - 예측대상 1"),
"depth_mm": (float, {"min": 0, "max": 30}, "추정 결함 깊이 - 예측대상 2"),
"damage_idx": (float, {"min": 0, "max": 100}, "손상도 점수 - 예측대상 3"),
"grade": (int, {"enum": [1, 2, 3]}, "정비 등급(현장/야전/창정비) - 예측대상 4"),
"confidence": (float, {"min": 0, "max": 1}, "판정 신뢰도 지수"),
},
}
def check(name, v, typ, rule):
if not isinstance(v, typ) or (typ in (int, float) and isinstance(v, bool)):
return [f"{name}: 자료형 오류({type(v).__name__})"]
e = []
if "enum" in rule and v not in rule["enum"]:
e.append(f"{name}: 허용되지 않은 값 '{v}'")
if "min" in rule and v < rule["min"]:
e.append(f"{name}: 최소값 {rule['min']} 미만")
if "max" in rule and v > rule["max"]:
e.append(f"{name}: 최대값 {rule['max']} 초과")
if "pattern" in rule and not re.match(rule["pattern"], v):
e.append(f"{name}: 형식 불일치 '{v}'")
return e
def validate(rec):
errs = []
for g, fields in S.items():
for k, (typ, rule, _) in fields.items():
if k not in rec.get(g, {}):
errs.append(f"{g}.{k}: 필수 필드 누락")
else:
errs += check(f"{g}.{k}", rec[g][k], typ, rule)
b = rec.get("label", {})
if all(k in b for k in ("bbox_x", "bbox_w", "bbox_y", "bbox_h")) and (b["bbox_x"] + b["bbox_w"] > 64 or b["bbox_y"] + b["bbox_h"] > 64):
errs.append("label.bbox: 박스가 이미지 밖으로 나감")
if rec.get("result", {}).get("grade") in (1, 2, 3) and rec.get("spec", {}).get("reg_grade") in (1, 2, 3):
if abs(rec["result"]["grade"] - rec["spec"]["reg_grade"]) >= 2:
errs.append("제원 대조: 판정 등급과 관리 기록 등급이 2단계 이상 차이(제원 이상 신호)")
return errs
# 4) [교육용가상자료] 샘플 20건
rng = np.random.default_rng(1)
recs = []
for i in range(20):
g = int(rng.integers(1, 4))
recs.append({
"photo": {"img_id": f"IMG{i:06d}", "shot_day": int(rng.integers(0, 90)), "width": 64, "height": 64, "channels": 3,
"brightness": float(round(rng.uniform(0.2, 0.9), 2)), "mud_ratio": float(round(rng.uniform(0, 0.3), 2))},
"spec": {"equip_id": f"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}", "equip_type": str(rng.choice(["vehicle", "generator", "comm", "trailer"])),
"mfg_year": int(rng.integers(2000, 2025)), "op_hours": float(round(rng.uniform(100, 9000), 0)), "env": str(rng.choice(["coastal", "inland"])),
"reg_grade": int(np.clip(g + rng.integers(-1, 2), 1, 3))},
"label": {"defect_class": str(rng.choice(["none", "crack", "corrosion", "damage", "leak"])), "bbox_x": int(rng.integers(0, 30)), "bbox_y": int(rng.integers(0, 30)),
"bbox_w": int(rng.integers(5, 30)), "bbox_h": int(rng.integers(5, 30)), "emergency": bool(rng.random() < 0.05)},
"result": {"area_frac": float(round(rng.uniform(0, 0.2), 3)), "depth_mm": float(round(rng.uniform(0, 6), 1)), "damage_idx": float(round(rng.uniform(0, 100), 1)),
"grade": g, "confidence": float(round(rng.uniform(0.5, 1), 2))}})
ok = sum(not validate(r) for r in recs)
print(f"정상 샘플 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건")
# 5) 오류 주입 시험
bad = json.loads(json.dumps(recs[0]))
bad["spec"]["mfg_year"] = 2085 # 제원 값 범위 이상(미래 제조연도)
bad["label"]["bbox_x"] = 60; bad["label"]["bbox_w"] = 20 # 박스가 사진 밖으로 나감
bad["result"]["grade"] = 5 # 허용되지 않은 등급
del bad["spec"]["op_hours"] # 가동시간 누락
bad["photo"]["brightness"] = "어두움" # 자료형 오류
print("\n[오류 주입 결과]")
for e in validate(bad):
print(" -", e)
mis = json.loads(json.dumps(recs[1])); mis["result"]["grade"] = 3; mis["spec"]["reg_grade"] = 1
print("제원 불일치 시험:", validate(mis))
# 6) 프로세스 맵과 데이터 딕셔너리
print("\n[업무 프로세스 맵]")
print(" -> ".join(f"{i}.{p}" for i, (p, _) in enumerate(PROCESS, 1)))
for p, d in PROCESS:
print(f" {p:10s}: {d}")
print("\n[라벨링 기준]")
for k, v in LABEL_RULES.items():
print(f" {k:10s}: {v}")
rows = [(g, k, t.__name__, str(r) if r else "-", d) for g, f in S.items() for k, (t, r, d) in f.items()]
dd = pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙", "설명"])
print(f"\n[데이터 딕셔너리] 피처 {len(dd)}개 (목표 20개 이상: {'달성' if len(dd) >= 20 else '미달'})")
print(dd[["그룹", "필드", "자료형", "설명"]].to_string(index=False))
print("\n예측 대상: 결함 면적, 추정 깊이, 손상도 점수, 정비 등급. 제원 활용: 사진 판정과 관리 기록 등급이 크게 다르면 제원 이상 신호로 표시")
```
실행 결과 예시
```text
정상 샘플 20건 중 검증 통과 20건, 오류 0건
[오류 주입 결과]
- photo.brightness: 자료형 오류(str)
- spec.mfg_year: 최대값 2026 초과
- spec.op_hours: 필수 필드 누락
- result.grade: 허용되지 않은 값 '5'
- label.bbox: 박스가 이미지 밖으로 나감
제원 불일치 시험: ['제원 대조: 판정 등급과 관리 기록 등급이 2단계 이상 차이(제원 이상 신호)']
[업무 프로세스 맵]
1.촬영 -> 2.제원 대조 -> 3.전처리 -> 4.결함 판별 -> 5.손상도 산정 -> 6.검사관 보정 -> 7.정비 등급 승인 -> 8.제원 환류 -> 9.사후검토(AAR)
촬영 : 사진 ID, 촬영일, 촬영 조건(조명·오염)
제원 대조 : 장비 식별코드, 제조연도, 가동시간, 기록상 정비 등급
전처리 : 조명 보정, 해상도 표준화, 결함 영역(바운딩 박스)
결함 판별 : 결함 유형, 결함 면적, 추정 깊이
손상도 산정 : 손상도 점수(0~100), 판정 신뢰도
검사관 보정 : 수동 보정 여부, 보정 사유
정비 등급 승인 : 현장·야전·창정비 등급, 승인자, 승인 시각
제원 환류 : 판정 결과와 기록상 등급의 불일치, 제원 수정 요청
사후검토(AAR) : 오판 원인, 파라미터 재보정 이력
[라벨링 기준]
crack : 선형 어두운 균열, 길이 10px 이상
corrosion : 붉은 갈색 면, 면적 12px 이상
damage : 함몰·파단 윤곽(밝은 테두리 포함)
leak : 어둡고 번지는 액체 자국(아래 방향 흐름)
none : 결함 없음(무해한 얼룩·긁힘은 결함으로 라벨하지 않음)
[데이터 딕셔너리] 피처 24개 (목표 20개 이상: 달성)
그룹 필드 자료형 설명
photo img_id str 사진 ID
photo shot_day int 촬영일(점검 시작 후 경과일)
photo width int 표준 해상도 가로(px)
photo height int 표준 해상도 세로(px)
photo channels int 채널 수(RGB)
photo brightness float 평균 밝기(0~1)
photo mud_ratio float 진흙·오염 가림 비율(추정)
spec equip_id str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)
spec equip_type str 장비 구분
... (이하 생략)
```
## 미션 2. 이미지 조도 보정, 해상도 표준화 및 결함 영역(Bounding Box) 전처리 모듈 구현
- 난이도: 초급~중급 / 예상 소요시간: 2.0시간
- 코드 설명: 해상도·밝기·표기가 제각각인 장비 외관 사진과 제원 기록, 결함 영역(바운딩 박스) 라벨의 가상 원천 자료에 조명 보정·해상도 표준화·박스 정리를 포함한 정제 규칙 11종을 적용하고, 긴급 점검 사진 같은 군수 예외 데이터는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다.
```python
"""해상도·밝기·표기가 제각각인 장비 외관 사진과 제원 기록, 결함 영역(바운딩 박스) 라벨의 가상 원천 자료에 조명 보정·해상도 표준화·박스 정리를 포함한 정제 규칙 11종을 적용하고, 긴급 점검 사진 같은 군수 예외 데이터는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다."""
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.impute import KNNImputer
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
rng = np.random.default_rng(2)
meta, imgs, masks = make_images(150, seed=2)
n0 = len(meta)
applied = []
def rule(name, n):
applied.append((name, int(n)))
def true_box(m):
ys, xs = np.where(m)
return (int(xs.min()), int(ys.min()), int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1)) if len(xs) else None
# ---------------- 1) 원천 자료 만들기(일부러 넣은 오류) ----------------
raw_imgs, rows = [], []
sizes = rng.choice([32, 64, 128], n0, p=[0.2, 0.55, 0.25])
dark_set = set(rng.choice(n0, 35, replace=False))
emerg = set(rng.choice(n0, 6, replace=False))
for i in range(n0):
im = imgs[i]
if sizes[i] != 64:
im = np.clip(ndi.zoom(im, (sizes[i] / 64, sizes[i] / 64, 1), order=1), 0, 1)
if i in dark_set or i in emerg:
im = im * 0.45 # 저조도 촬영
raw_imgs.append(im.astype(np.float32))
tb = true_box(masks[i]); k = sizes[i] / 64
box = None if tb is None else [tb[0] * k, tb[1] * k, tb[2] * k, tb[3] * k]
fmt = "xywh"
if box is not None and rng.random() < 0.4: # 일부는 xyxy 형식으로 기록
box, fmt = [box[0], box[1], box[0] + box[2], box[1] + box[3]], "xyxy"
rows.append({"img_id": f"IMG{i:06d}", "shot": f"2026-{rng.integers(1, 10):02d}-{rng.integers(1, 28):02d}", "equip_id": f"VX-{1000 + i:04d}-{2000 + i:04d}",
"mfg_year": int(meta.loc[i, "mfg_year"]), "op_hours": float(meta.loc[i, "op_hours"]), "hours_unit": "h", "reg_grade": float(meta.loc[i, "reg_grade"]),
"w": int(sizes[i]), "box": box, "box_fmt": fmt, "emergency": i in emerg})
raw = pd.DataFrame(rows)
idx = rng.choice(n0, 60, replace=False)
a, b, c, d, e, f, g, h = idx[:8], idx[8:16], idx[16:24], idx[24:32], idx[32:40], idx[40:46], idx[46:52], idx[52:60]
raw.loc[a, "img_id"] = raw.loc[a, "img_id"].str.replace("IMG", "img-") # 사진 ID 표기 불일치
raw.loc[b, "shot"] = [x.replace("-", "") for x in raw.loc[b, "shot"]] # 촬영일 형식 혼재
raw.loc[c, "op_hours"] = raw.loc[c, "op_hours"] / 24; raw.loc[c, "hours_unit"] = "day" # 가동시간 단위 혼재
raw.loc[d, "mfg_year"] = rng.choice([1900, 2085], len(d)) # 불가능한 제조연도
raw.loc[e, ["mfg_year", "op_hours", "reg_grade"]] = np.nan # 제원 결측
for i in f: # 박스가 사진 밖으로 나감
if raw.at[i, "box"] is not None and raw.at[i, "box_fmt"] == "xywh":
raw.at[i, "box"] = [raw.at[i, "w"] - 2, raw.at[i, "box"][1], raw.at[i, "box"][2] + 6, raw.at[i, "box"][3]]
for i in g: # 폭 0 박스
if raw.at[i, "box"] is not None and raw.at[i, "box_fmt"] == "xywh":
raw.at[i, "box"] = [raw.at[i, "box"][0], raw.at[i, "box"][1], 0, 0]
dup_rows = raw.sample(8, random_state=1)
raw = pd.concat([raw, dup_rows], ignore_index=True)
raw_imgs += [raw_imgs[int(r["img_id"][-6:])] for _, r in dup_rows.iterrows()] # 중복 사진
print(f"원천 사진 {len(raw)}장 (중복 8장, 오류 주입 {len(idx)}건, 저조도 {len(dark_set | emerg)}장, 긴급 점검 {len(emerg)}장 포함)")
# ---------------- 2) 정제 규칙 11종 ----------------
df, ims = raw.copy(), list(raw_imgs)
dup = df.duplicated(["img_id", "equip_id", "shot"]); rule("R1 중복 사진 제거", dup.sum())
keep = (~dup).values; df = df[keep].reset_index(drop=True); ims = [im for im, k in zip(ims, keep) if k]
bad_id = ~df["img_id"].str.match(r"^IMG\d{6}$"); df["img_id"] = "IMG" + df["img_id"].str.extract(r"(\d{6})")[0]; rule("R2 사진 ID 표기 통일", bad_id.sum())
t1 = pd.to_datetime(df["shot"], format="%Y-%m-%d", errors="coerce"); t2 = pd.to_datetime(df["shot"], format="%Y%m%d", errors="coerce")
rule("R3 촬영일 형식 통일", t1.isna().sum()); df["shot"] = t1.fillna(t2)
day = df["hours_unit"] == "day"; df.loc[day, "op_hours"] *= 24; df["hours_unit"] = "h"; rule("R4 가동시간 단위 통일(일 -> 시간)", day.sum())
bad = ~df["mfg_year"].between(1990, 2026) & df["mfg_year"].notna(); df.loc[bad, "mfg_year"] = np.nan; rule("R5 불가능 제조연도 결측 처리", bad.sum())
nres = sum(im.shape[0] != 64 for im in ims)
ims = [np.clip(ndi.zoom(im, (64 / im.shape[0], 64 / im.shape[1], 1), order=1), 0, 1) if im.shape[0] != 64 else im for im in ims]
rule("R6 해상도 표준화(32/128 -> 64px)", nres)
before_b = np.array([im.mean() for im in ims])
fixed = []
for im in ims:
cc, _ = correct(im) # 보정 밝기 = 밝기 / 조명 평면
plane = im.mean(axis=2) / np.maximum(cc, 1e-6)
fixed.append(np.clip(im * 0.45 / np.maximum(plane, 1e-3)[..., None], 0, 1)) # 조명 기울기를 펴고 정상 면 밝기를 0.45로 맞춘다
ims = fixed; rule("R7 조명 보정·정규화(평균 밝기 0.3 미만 사진 수)", (before_b < 0.3).sum())
bx = []
nx = 0
for (_, r) in df.iterrows():
if r["box"] is None:
bx.append(None); continue
x, y, w_, h_ = r["box"]
if r["box_fmt"] == "xyxy":
w_, h_ = w_ - x, h_ - y; nx += 1
k = 64 / r["w"]
bx.append([x * k, y * k, w_ * k, h_ * k])
rule("R8 박스 형식 통일(xyxy -> xywh, 64px 좌표)", nx)
clip_n, zero_n, boxes, manual = 0, 0, [], []
for i, b_ in enumerate(bx):
if b_ is None:
boxes.append(None); continue
x, y, w_, h_ = b_
x2, y2 = min(x + w_, 64), min(y + h_, 64)
if x2 > 64 or x + w_ > 64.5 or y + h_ > 64.5:
clip_n += 1
if x2 - x < 1 or y2 - y < 1:
zero_n += 1; manual.append(df.loc[i, "img_id"]); boxes.append(None); continue
boxes.append([int(round(x)), int(round(y)), int(round(x2 - x)), int(round(y2 - y))])
rule("R9 박스 좌표 클리핑·무효 박스 수동 확인 분리", clip_n + zero_n)
miss = df[["mfg_year", "op_hours", "reg_grade"]].isna().sum().sum()
df[["mfg_year", "op_hours", "reg_grade"]] = KNNImputer(n_neighbors=5).fit_transform(df[["mfg_year", "op_hours", "reg_grade", "w"]])[:, :3]
df["mfg_year"] = df["mfg_year"].round().astype(int); df["reg_grade"] = df["reg_grade"].round().astype(int); rule("R10 제원 결측 KNN 보정", miss)
df["quality_flag"] = np.array([im.std() < 0.03 for im in ims]) & ~df["emergency"]
rule("R11 긴급 점검 사진 보존·일반 저화질 표시", int(df["emergency"].sum()))
# ---------------- 3) 정합성 검증 ----------------
after_b = np.array([im.mean() for im in ims])
tb = {r["img_id"]: true_box(masks[int(r["img_id"][-6:])]) for _, r in df.iterrows()}
ious = []
for (_, r), bb in zip(df.iterrows(), boxes):
t = tb[r["img_id"]]
if bb is not None and t is not None:
ix = max(0, min(bb[0] + bb[2], t[0] + t[2]) - max(bb[0], t[0])); iy = max(0, min(bb[1] + bb[3], t[1] + t[3]) - max(bb[1], t[1]))
ious.append(ix * iy / (bb[2] * bb[3] + t[2] * t[3] - ix * iy))
chk = {"중복 사진 0장": not df["img_id"].duplicated().any(), "사진 ID 형식": df["img_id"].str.match(r"^IMG\d{6}$").all(), "촬영일 변환": df["shot"].notna().all(),
"가동시간 단위 h": (df["hours_unit"] == "h").all(), "제조연도 1990~2026": df["mfg_year"].between(1990, 2026).all(),
"모든 사진 64x64x3": all(im.shape == (64, 64, 3) for im in ims), "밝기 편차 감소": after_b.std() < before_b.std() / 2,
"박스가 사진 안": all(b_ is None or (b_[0] + b_[2] <= 64 and b_[1] + b_[3] <= 64) for b_ in boxes), "박스 면적 양수": all(b_ is None or (b_[2] > 0 and b_[3] > 0) for b_ in boxes),
"제원 결측 0건": df[["mfg_year", "op_hours", "reg_grade"]].notna().all().all(), f"긴급 점검 {int(df['emergency'].sum())}장 보존": int(df["emergency"].sum()) == len(emerg),
"정제 박스와 정답 박스 평균 IoU 0.9 이상": float(np.mean(ious)) >= 0.9}
rate = np.mean(list(chk.values()))
print("\n[적용 규칙과 처리 건수]")
for nme, n in applied:
print(f" {nme:44s} {n:4d}건")
print(f"\n정합성 검증 {sum(chk.values())}/{len(chk)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'}), 규칙 {len(applied)}종 (목표 10종 이상: {'달성' if len(applied) >= 10 else '미달'})")
for k, v in chk.items():
if not v:
print(" 실패:", k, "(IoU %.2f)" % np.mean(ious))
print(f"정제 후 사진 {len(df)}장, 박스 수동 확인 {len(manual)}장, 긴급 점검 {int(df['emergency'].sum())}장 유지, 정제 박스 평균 IoU {np.mean(ious):.2f}")
cmp = pd.DataFrame({"정제 전": [before_b.mean(), before_b.std(), raw["op_hours"].mean(), raw["mfg_year"].min(), raw["mfg_year"].max()],
"정제 후": [after_b.mean(), after_b.std(), df["op_hours"].mean(), df["mfg_year"].min(), df["mfg_year"].max()]},
index=["평균 밝기", "밝기 표준편차", "평균 가동시간(h)", "최소 제조연도", "최대 제조연도"]).round(3)
print("\n[정제 전후 통계]"); print(cmp.to_string())
fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))
ax[0].hist(before_b, bins=25, alpha=0.6, label="raw"); ax[0].hist(after_b, bins=25, alpha=0.6, label="normalized"); ax[0].set_title("Mean brightness"); ax[0].legend()
j = int(np.argmin(before_b))
ax[1].imshow(raw_imgs[j][..., :] / max(raw_imgs[j].max(), 1e-3)); ax[1].set_title("dark photo (raw, stretched)"); ax[1].axis("off")
ax[2].imshow(ims[j]); ax[2].set_title("after normalization"); ax[2].axis("off")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
원천 사진 158장 (중복 8장, 오류 주입 60건, 저조도 40장, 긴급 점검 6장 포함)
[적용 규칙과 처리 건수]
R1 중복 사진 제거 8건
R2 사진 ID 표기 통일 8건
R3 촬영일 형식 통일 8건
R4 가동시간 단위 통일(일 -> 시간) 8건
R5 불가능 제조연도 결측 처리 8건
R6 해상도 표준화(32/128 -> 64px) 71건
R7 조명 보정·정규화(평균 밝기 0.3 미만 사진 수) 74건
R8 박스 형식 통일(xyxy -> xywh, 64px 좌표) 54건
R9 박스 좌표 클리핑·무효 박스 수동 확인 분리 4건
R10 제원 결측 KNN 보정 32건
R11 긴급 점검 사진 보존·일반 저화질 표시 6건
정합성 검증 12/12 통과, 통과율 100% (목표 100%: 달성), 규칙 11종 (목표 10종 이상: 달성)
정제 후 사진 150장, 박스 수동 확인 3장, 긴급 점검 6장 유지, 정제 박스 평균 IoU 0.99
[정제 전후 통계]
정제 전 정제 후
평균 밝기 0.293 0.447
밝기 표준편차 0.117 0.005
평균 가동시간(h) 2494.475 2609.453
최소 제조연도 1900.000 2002.000
최대 제조연도 2085.000 2025.000
```
## 미션 3. 외관 결함 유무 1차 판별 Baseline 비전 모델 구현
- 난이도: 중급 / 예상 소요시간: 2.5시간
- 코드 설명: 밝기 히스토그램과 평균 색만 쓰는 외관 결함 유무 1차 판별 Baseline 비전 모델(로지스틱 회귀)을 구현해 성능과 한계(작은 균열·조명 의존)를 확인하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·정비 공수 파레토(ABC)·제원 기록과의 불일치 분석으로 핵심 관리 대상을 찾는다.
```python
"""밝기 히스토그램과 평균 색만 쓰는 외관 결함 유무 1차 판별 Baseline 비전 모델(로지스틱 회귀)을 구현해 성능과 한계(작은 균열·조명 의존)를 확인하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·정비 공수 파레토(ABC)·제원 기록과의 불일치 분석으로 핵심 관리 대상을 찾는다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
mtr, itr, _ = make_images(400, seed=1)
mte, ite, kte = make_images(300, seed=2)
def hist_feat(imgs):
"""Baseline 특징: 밝기 히스토그램 16칸 + 채널 평균 3 + 밝기 표준편차 1."""
out = []
for im in imgs:
g = im.mean(axis=2)
h, _ = np.histogram(g / max(g.max(), 1e-3), bins=16, range=(0, 1))
out.append(np.r_[h / g.size, im.mean(axis=(0, 1)) / max(g.mean(), 1e-3), g.std() / max(g.mean(), 1e-3)])
return np.array(out)
Htr, Hte = hist_feat(itr), hist_feat(ite)
ytr, yte = (mtr["cls"] != "none").values, (mte["cls"] != "none").values
base = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit(Htr, ytr)
pr = base.predict(Hte)
print(f"[Baseline 결과] 학습 {len(mtr)}장 / 평가 {len(mte)}장, 결함 사진 {yte.mean():.1%}")
print(f"정확도 {accuracy_score(yte, pr):.1%}, 정밀도 {precision_score(yte, pr):.1%}, 재현율 {recall_score(yte, pr):.1%}, F1 {f1_score(yte, pr):.3f}")
rec = pd.DataFrame({"cls": mte["cls"], "hit": pr == yte}).query("cls != 'none'").groupby("cls")["hit"].mean().round(2)
print("결함 유형별 재현율:", ", ".join(f"{KO_CLS[k]} {v:.0%}" for k, v in rec.items()))
small = (mte["area_frac"] > 0) & (mte["area_frac"] < 0.02)
print(f"면적 2% 미만 작은 결함 {int(small.sum())}장 재현율 {pr[small.values].mean():.0%} (큰 결함 {pr[(mte['area_frac'] >= 0.02).values].mean():.0%})")
dk = mte["gain"] < 0.6
print(f"조명이 어두운 사진(gain<0.6) 정확도 {(pr == yte)[dk.values].mean():.0%} vs 밝은 사진 {(pr == yte)[~dk.values].mean():.0%}")
print("-> 전체 통계만 보는 Baseline은 작은 균열을 놓치고 조명에 흔들린다. 결함 위치·모양을 보는 분할 특징이 필요하다(미션 4~5의 동기).")
# 1) 상관분석: 결함 유무와의 관계(점이연 상관, 유의수준 0.05) - 사진 특징 + 장비 제원 기록
Fall = pd.concat([feature_table(ite), feature_table(itr[:200])], ignore_index=True)
mall = pd.concat([mte, mtr[:200]], ignore_index=True)
Fall["op_hours"], Fall["reg_grade"], Fall["mfg_year"] = mall["op_hours"], mall["reg_grade"], mall["mfg_year"]
Fall["coastal"] = (mall["env"] == "coastal").astype(int)
yall = (mall["cls"] != "none").astype(float)
rows = []
for f in ["c_std", "c_min", "edge_p95", "dark_frac", "rust_frac", "sat", "op_hours", "reg_grade", "mfg_year", "coastal"]:
r, p = stats.pearsonr(Fall[f].astype(float), yall)
rows.append({"피처": f, "상관계수": round(r, 3), "p-value": f"{p:.1e}", "유의": p < 0.05})
corr = pd.DataFrame(rows).sort_values("상관계수", key=abs, ascending=False)
print("\n[결함 유무와의 상관분석]")
print(corr.to_string(index=False))
n_sig = int(corr["유의"].sum())
print(f"유의미한 피처 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})")
# 2) 제원 기록과의 불일치: 사진 판정 등급 vs 관리 기록 등급
ct = pd.crosstab(mall["grade"], mall["reg_grade"]).rename(index=GRADE_NAME, columns=GRADE_NAME)
gap = (mall["grade"] - mall["reg_grade"]).abs() >= 2
print(f"\n[제원 대조] 사진 등급과 관리 기록 등급이 2단계 이상 다른 장비 {int(gap.sum())}대({gap.mean():.1%}) -> 제원 이상 신호 후보")
print(ct.to_string())
# 3) 파레토(ABC): 정비 공수(가상)가 큰 (장비 구분, 결함 유형) 조합
HOURS = {1: 2, 2: 8, 3: 30}
mall["hours"] = mall["grade"].map(HOURS)
g = mall[mall["cls"] != "none"].groupby(["equip_type", "cls"])["hours"].sum().sort_values(ascending=False)
cum = g.cumsum() / g.sum()
abc = pd.cut(cum, [0, 0.8, 0.95, 1.0], labels=["A", "B", "C"])
print("\n[파레토 A등급(누적 정비 공수 80%)]", ", ".join(f"{e}/{KO_CLS[c]}" for (e, c), v in abc.items() if v == "A"))
print(f"A등급은 전체 조합 {len(g)}개 중 {int((abc == 'A').sum())}개({(abc == 'A').mean():.0%})")
# 4) EDA 차트 8종
fig, ax = plt.subplots(2, 4, figsize=(18, 8))
a = ax.ravel()
a[0].imshow(np.concatenate([ite[int(np.where(mte['cls'] == c)[0][0])] for c in CLASSES], axis=1)); a[0].set_title("1. Samples: none/crack/corrosion/damage/leak"); a[0].axis("off")
mall["cls"].value_counts().plot.bar(ax=a[1]); a[1].set_title("2. Photos by defect class")
a[2].hist(mall.loc[yall == 0, "gain"], bins=20, alpha=0.6, label="none"); a[2].hist(mall.loc[yall == 1, "gain"], bins=20, alpha=0.6, label="defect"); a[2].legend(); a[2].set_title("3. Lighting gain")
mall.boxplot(column="damage_idx", by="cls", ax=a[3], rot=30); a[3].set_title("4. Damage index by class")
a[4].imshow(ct.values, cmap="Blues"); a[4].set_xticks(range(3), [1, 2, 3]); a[4].set_yticks(range(3), [1, 2, 3]); a[4].set_xlabel("registered grade"); a[4].set_ylabel("photo grade"); a[4].set_title("5. Photo vs registered grade")
a[5].scatter(mall["op_hours"], mall["damage_idx"], s=6, c=mall["env"].map({"coastal": "tab:red", "inland": "tab:blue"})); a[5].set_title("6. Op hours vs damage (red = coastal)")
a[6].barh(corr["피처"], corr["상관계수"]); a[6].set_title("7. Correlation with defect")
a[7].plot(range(1, len(cum) + 1), cum.values, "o-"); a[7].axhline(0.8, ls="--", c="gray"); a[7].set_title("8. Pareto of repair hours")
plt.suptitle("QLT03 EDA - baseline defect screening (synthetic photos)"); plt.tight_layout(); plt.show()
print("\nEDA 차트 8종 생성 (목표 8종 이상: 달성)")
```
실행 결과 예시
```text
[Baseline 결과] 학습 400장 / 평가 300장, 결함 사진 76.7%
정확도 86.0%, 정밀도 89.8%, 재현율 92.2%, F1 0.910
결함 유형별 재현율: 부식 96%, 균열 75%, 파손 96%, 누유 98%
면적 2% 미만 작은 결함 31장 재현율 74% (큰 결함 95%)
조명이 어두운 사진(gain<0.6) 정확도 82% vs 밝은 사진 87%
-> 전체 통계만 보는 Baseline은 작은 균열을 놓치고 조명에 흔들린다. 결함 위치·모양을 보는 분할 특징이 필요하다(미션 4~5의 동기).
[결함 유무와의 상관분석]
피처 상관계수 p-value 유의
c_min -0.711 2.7e-78 True
c_std 0.496 1.8e-32 True
dark_frac 0.457 3.5e-27 True
edge_p95 0.373 5.4e-18 True
reg_grade 0.272 6.2e-10 True
rust_frac 0.242 4.5e-08 True
sat 0.186 3.0e-05 True
op_hours 0.066 1.4e-01 False
mfg_year 0.045 3.1e-01 False
coastal 0.004 9.2e-01 False
유의미한 피처 7개 (목표 5개 이상: 달성)
[제원 대조] 사진 등급과 관리 기록 등급이 2단계 이상 다른 장비 47대(9.4%) -> 제원 이상 신호 후보
reg_grade 현장정비 야전정비 창정비
grade
현장정비 219 50 47
야전정비 8 58 28
창정비 0 15 75
[파레토 A등급(누적 정비 공수 80%)] vehicle/파손, comm/파손, vehicle/부식, trailer/파손, generator/파손, generator/부식
A등급은 전체 조합 16개 중 6개(38%)
EDA 차트 8종 생성 (목표 8종 이상: 달성)
```
## 미션 4. 세그멘테이션(Mask R-CNN) 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델 고도화
- 난이도: 중급 / 예상 소요시간: 3.0시간
- 코드 설명: 분할(thresholding 기반 결함 후보 분할, Mask R-CNN 대체) 결과에서 결함 면적을 측정·보정하고 깊이와 손상도 점수를 학습해 손상도를 자동 판정하며, 분할 IoU·면적/깊이/손상도 오차(MAPE)와 변수군 제거 실험으로 파생 피처 설계의 타당성을 검증한다.
```python
"""분할(thresholding 기반 결함 후보 분할, Mask R-CNN 대체) 결과에서 결함 면적을 측정·보정하고 깊이와 손상도 점수를 학습해 손상도를 자동 판정하며, 분할 IoU·면적/깊이/손상도 오차(MAPE)와 변수군 제거 실험으로 파생 피처 설계의 타당성을 검증한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import r2_score
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----
def fit_pipeline(meta, F):
"""결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다."""
clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)
P = cross_val_predict(clf, F, meta["cls"], cv=4, method="predict_proba")
clf.fit(F, meta["cls"])
reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta["damage_idx"])
return {"clf": clf, "reg": reg, "cols": list(F.columns)}
def predict_pipeline(pl, F):
X = F[pl["cols"]]
P = pl["clf"].predict_proba(X)
idx = np.clip(pl["reg"].predict(np.c_[X.values, P]), 0, 100)
return pl["clf"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)
mtr, itr, ktr = make_images(600, seed=1)
mte, ite, kte = make_images(300, seed=2)
Ftr, Fte = feature_table(itr), feature_table(ite)
print(f"[1] 파생 피처 {Ftr.shape[1]}개(분할 면적·성분 수·모양, 명암, 색, 가장자리, 위치) / 학습 {len(mtr)}장, 평가 {len(mte)}장 (1화소 = 1.5mm 가정)")
# ---------------- 1) 분할 정밀도: 정답 마스크와의 IoU ----------------
seg = [segment(im)[0] for im in ite]
rows = []
for c in CLASSES[1:]:
ix = np.where(mte["cls"].values == c)[0]
iou = [(seg[i] & kte[i]).sum() / max((seg[i] | kte[i]).sum(), 1) for i in ix]
dice = [2 * (seg[i] & kte[i]).sum() / max(seg[i].sum() + kte[i].sum(), 1) for i in ix]
hit = [seg[i].sum() > 0 and (seg[i] & kte[i]).sum() > 0 for i in ix]
rows.append((KO_CLS[c], len(ix), np.mean(iou), np.mean(dice), np.mean(hit)))
sg = pd.DataFrame(rows, columns=["결함", "장수", "평균 IoU", "평균 Dice", "검출률"])
print("[2] 분할 성능(평가 300장)\n" + sg.round(2).to_string(index=False))
ok0 = np.mean([seg[i].sum() == 0 or (seg[i].sum() < 12) for i in np.where(mte["cls"].values == "none")[0]])
print(f" 결함 없는 사진 {int((mte['cls'] == 'none').sum())}장 중 분할 결과가 거의 비어 있는(12화소 미만) 사진 {ok0:.0%} (무해한 얼룩·스크래치 오탐 억제)")
# ---------------- 2) 면적 측정과 유형별 보정 ----------------
pl = fit_pipeline(mtr, Ftr)
cls_te, P_te, idx_te, conf_te = predict_pipeline(pl, Fte)
a_true, a_raw = mte["area_frac"].values, Fte["area_frac"].values
cal = {c: LinearRegression().fit(Ftr.loc[mtr["cls"].values == c, ["area_frac"]], mtr.loc[mtr["cls"].values == c, "area_frac"]) for c in CLASSES[1:]}
a_cal = np.array([cal[c].predict(pd.DataFrame({"area_frac": [a]}))[0] if c != "none" else 0.0 for c, a in zip(cls_te, a_raw)])
m = a_true > 0
mp = lambda y, p: float(np.mean(np.abs(y - p) / y) * 100)
print(f"\n[3] 면적 측정(결함 {int(m.sum())}장): 분할 원측정 MAPE {mp(a_true[m], a_raw[m]):.1f}% -> 유형별 선형 보정 후 {mp(a_true[m], a_cal[m]):.1f}% (분류 정확도 {(cls_te == mte['cls'].values).mean():.1%})")
print(f" 면적 환산 예: 평균 결함 면적 {a_true[m].mean() * SZ ** 2 * 1.5 ** 2:.0f}mm2(정답) / {a_cal[m].mean() * SZ ** 2 * 1.5 ** 2:.0f}mm2(보정 측정)")
# ---------------- 3) 깊이·손상도 모델 ----------------
dm_tr = mtr["depth_mm"].values > 0.2
Xtr = np.c_[Ftr[pl["cols"]].values, cross_val_predict(RandomForestClassifier(n_estimators=100, random_state=0, n_jobs=1), Ftr, mtr["cls"], cv=3, method="predict_proba")]
dep = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(Xtr[dm_tr], mtr["depth_mm"].values[dm_tr])
d_pred = np.clip(dep.predict(np.c_[Fte[pl["cols"]].values, P_te]), 0, None)
dm = mte["depth_mm"].values > 0.2
print(f"[4] 깊이 추정(깊이 있는 유형 {int(dm.sum())}장, 누유는 깊이 0으로 제외): MAPE {mp(mte['depth_mm'].values[dm], d_pred[dm]):.1f}%, MAE {np.mean(np.abs(mte['depth_mm'].values[dm] - d_pred[dm])):.2f}mm")
print(" 깊이는 사진 한 장으로 직접 관측되지 않아 면적·명암에서 간접 추정하므로 손상도보다 오차가 크다(실제 업무는 깊이 게이지 등 현장 측정으로 확인).")
y = mte["damage_idx"].values
big = y >= 10
mape_d, mae_d = mp(y[big], idx_te[big]), float(np.mean(np.abs(y - idx_te)))
base = LinearRegression().fit(Ftr[["area_px"]], mtr["damage_idx"])
b_pred = np.clip(base.predict(Fte[["area_px"]]), 0, 100)
print(f"[5] 손상도 점수(0~100, 점수 10 이상 {int(big.sum())}장): 면적만 쓰는 기준 MAPE {mp(y[big], b_pred[big]):.1f}% -> 제안 모델 MAPE {mape_d:.1f}% (목표 25% 이하: {'달성' if mape_d <= 25 else '미달'}), MAE {mae_d:.2f}, R2 {r2_score(y, idx_te):.3f}")
g_true, g_pred = mte["grade"].values, grade_of(idx_te)
print(f" 정비 등급 일치율 {(g_true == g_pred).mean():.1%}, 한 단계 이내 {(np.abs(g_true - g_pred) <= 1).mean():.1%}")
# ---------------- 4) 변수군 제거 실험 ----------------
groups = {"분할 면적·모양": ["area_px", "area_frac", "n_comp", "big_px", "elong", "fill", "thin", "cy", "cx"], "명암(어두운 비율·대비)": ["dark_frac", "c_mean", "c_std", "c_min", "in_dark", "ring_contrast"],
"색(붉은 갈색)": ["rust_frac", "chroma_mean", "chroma_max", "in_chroma", "sat"], "가장자리·테두리": ["edge_mean", "edge_p95", "rim_frac"], "조명": ["gain"]}
full_cols = list(Ftr.columns)
def run(cols):
g = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(Ftr[cols], mtr["damage_idx"])
p = np.clip(g.predict(Fte[cols]), 0, 100)
return mp(y[big], p[big])
full = run(full_cols)
ab = pd.DataFrame([(k, run([c for c in full_cols if c not in v])) for k, v in groups.items()], columns=["제거한 변수군", "MAPE(%)"]).assign(증가=lambda x: x["MAPE(%)"] - full).sort_values("증가", ascending=False)
print(f"\n[6] 변수군 제거(분류 확률 없이 24개 피처만 사용, 전체 MAPE {full:.1f}%)\n" + ab.round(2).to_string(index=False))
print(f" 해석: 제거 시 오차가 가장 크게 늘어난 변수군은 '{ab.iloc[0]['제거한 변수군']}'(+{ab.iloc[0]['증가']:.1f}%p)이다. 변수군끼리 정보가 겹쳐 단독 제거 효과는 작게 나타날 수 있다.")
print(" 판정은 정비 의사결정 보조이며 최종 등급은 검사관이 확정한다(M7). 사진·장비는 모두 교육용 가상 자료이다.")
fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))
ax[0].bar(range(len(sg)), sg["평균 IoU"]); ax[0].set_xticks(range(len(sg))); ax[0].set_xticklabels(["crack", "corrosion", "damage", "leak"]); ax[0].set_title("Segmentation IoU by defect type")
ax[1].scatter(a_true[m], a_raw[m], s=8, label="raw"); ax[1].scatter(a_true[m], a_cal[m], s=8, label="calibrated"); ax[1].plot([0, .2], [0, .2], "k--"); ax[1].legend(); ax[1].set_title("Defect area fraction: true vs measured")
ax[2].scatter(y, idx_te, s=8); ax[2].plot([0, 100], [0, 100], "k--"); ax[2].set_title("Damage index: true vs predicted")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[1] 파생 피처 24개(분할 면적·성분 수·모양, 명암, 색, 가장자리, 위치) / 학습 600장, 평가 300장 (1화소 = 1.5mm 가정)
[2] 분할 성능(평가 300장)
결함 장수 평균 IoU 평균 Dice 검출률
균열 48 0.89 0.94 1.0
부식 77 0.74 0.84 1.0
파손 57 0.61 0.71 1.0
누유 48 0.94 0.97 1.0
결함 없는 사진 70장 중 분할 결과가 거의 비어 있는(12화소 미만) 사진 100% (무해한 얼룩·스크래치 오탐 억제)
[3] 면적 측정(결함 230장): 분할 원측정 MAPE 20.8% -> 유형별 선형 보정 후 10.2% (분류 정확도 98.7%)
면적 환산 예: 평균 결함 면적 565mm2(정답) / 548mm2(보정 측정)
[4] 깊이 추정(깊이 있는 유형 181장, 누유는 깊이 0으로 제외): MAPE 28.0%, MAE 0.40mm
깊이는 사진 한 장으로 직접 관측되지 않아 면적·명암에서 간접 추정하므로 손상도보다 오차가 크다(실제 업무는 깊이 게이지 등 현장 측정으로 확인).
[5] 손상도 점수(0~100, 점수 10 이상 220장): 면적만 쓰는 기준 MAPE 30.8% -> 제안 모델 MAPE 8.8% (목표 25% 이하: 달성), MAE 3.22, R2 0.946
정비 등급 일치율 93.3%, 한 단계 이내 99.7%
[6] 변수군 제거(분류 확률 없이 24개 피처만 사용, 전체 MAPE 10.5%)
제거한 변수군 MAPE(%) 증가
색(붉은 갈색) 13.46 2.98
분할 면적·모양 11.45 0.97
명암(어두운 비율·대비) 11.29 0.81
조명 10.67 0.19
가장자리·테두리 10.60 0.12
해석: 제거 시 오차가 가장 크게 늘어난 변수군은 '색(붉은 갈색)'(+3.0%p)이다. 변수군끼리 정보가 겹쳐 단독 제거 효과는 작게 나타날 수 있다.
판정은 정비 의사결정 보조이며 최종 등급은 검사관이 확정한다(M7). 사진·장비는 모두 교육용 가상 자료이다.
```
## 미션 5. 정비 등급(현장정비/야전정비/창정비) 자동 분류 및 판정 신뢰도 지수 산출 엔진 개발
- 난이도: 중급~고급 / 예상 소요시간: 3.5시간
- 코드 설명: 손상도 점수 회귀와 결함 유형 확률로 정비 등급(현장정비·야전정비·창정비)을 자동 분류하고, 분류 확률·등급 경계까지의 거리·사진 품질로 판정 신뢰도 지수(0~100)를 산출하는 엔진을 만들어 기준 모델 대비 MAE·RMSE 개선과 신뢰도 보정, 저조도·진흙 사진 등 불규칙 상황에서의 안정성을 검증한다.
```python
"""손상도 점수 회귀와 결함 유형 확률로 정비 등급(현장정비·야전정비·창정비)을 자동 분류하고, 분류 확률·등급 경계까지의 거리·사진 품질로 판정 신뢰도 지수(0~100)를 산출하는 엔진을 만들어 기준 모델 대비 MAE·RMSE 개선과 신뢰도 보정, 저조도·진흙 사진 등 불규칙 상황에서의 안정성을 검증한다."""
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import r2_score, confusion_matrix
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----
def fit_pipeline(meta, F):
"""결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다."""
clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)
P = cross_val_predict(clf, F, meta["cls"], cv=4, method="predict_proba")
clf.fit(F, meta["cls"])
reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta["damage_idx"])
return {"clf": clf, "reg": reg, "cols": list(F.columns)}
def predict_pipeline(pl, F):
X = F[pl["cols"]]
P = pl["clf"].predict_proba(X)
idx = np.clip(pl["reg"].predict(np.c_[X.values, P]), 0, 100)
return pl["clf"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)
# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----
def mud_est(img):
"""진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다."""
sm = ndi.uniform_filter(img, size=(3, 3, 1))
s = sm.sum(axis=2) + 1e-6
rr, gg = sm[..., 0] / s, sm[..., 1] / s
return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())
def confidence(P, idx, F, mud):
"""판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다."""
bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))
qual = np.clip(1.0 - np.clip(0.55 - F["gain"].values, 0, None) * 2 - np.clip(0.9 - F["c_mean"].values, 0, None), 0, 1)
c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual
return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)
mtr, itr, _ = make_images(600, seed=1)
mte, ite, _ = make_images(300, seed=2)
Ftr, Fte = feature_table(itr), feature_table(ite)
pl = fit_pipeline(mtr, Ftr)
def hist_feat(imgs):
"""기준 모델(M3) 특징: 밝기 히스토그램 16칸 + 채널 평균 비율 3 + 변동계수 1."""
out = []
for im in imgs:
g = im.mean(axis=2)
h, _ = np.histogram(g / max(g.max(), 1e-3), bins=16, range=(0, 1))
out.append(np.r_[h / g.size, im.mean(axis=(0, 1)) / max(g.mean(), 1e-3), g.std() / max(g.mean(), 1e-3)])
return np.array(out)
base = LinearRegression().fit(hist_feat(itr), mtr["damage_idx"])
rm = lambda y, p: float(np.sqrt(np.mean((np.asarray(y) - np.asarray(p)) ** 2)))
# ---------------- 1) 평가 300장: 기준 모델 vs 제안 엔진 ----------------
cls, P, idx, _ = predict_pipeline(pl, Fte)
y = mte["damage_idx"].values
bp = np.clip(base.predict(hist_feat(ite)), 0, 100)
tab = pd.DataFrame({"기준(밝기 히스토그램 회귀)": [np.mean(np.abs(y - bp)), rm(y, bp), r2_score(y, bp)], "제안(분할 특징+유형 확률)": [np.mean(np.abs(y - idx)), rm(y, idx), r2_score(y, idx)]}, index=["MAE", "RMSE", "R2"])
print("[1] 손상도 점수 예측 성능(평가 300장)\n" + tab.round(3).to_string())
b, e = tab.iloc[:, 0], tab.iloc[:, 1]
ok = 1 - e["MAE"] / b["MAE"] >= .2 and 1 - e["RMSE"] / b["RMSE"] >= .2
print(f" MAE 개선 {1 - e['MAE'] / b['MAE']:.1%}, RMSE 개선 {1 - e['RMSE'] / b['RMSE']:.1%} (목표 20% 이상: {'달성' if ok else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= .85 else '미달'})")
# ---------------- 2) 정비 등급 분류 ----------------
g_true, g_pred = mte["grade"].values, grade_of(idx)
cm = pd.DataFrame(confusion_matrix(g_true, g_pred, labels=[1, 2, 3]), index=[f"실제 {GRADE_NAME[i]}" for i in (1, 2, 3)], columns=[f"판정 {GRADE_NAME[i]}" for i in (1, 2, 3)])
under = int(((g_pred < g_true)).sum())
print(f"\n[2] 정비 등급 판정: 일치율 {(g_true == g_pred).mean():.1%}, 한 단계 이내 {(np.abs(g_true - g_pred) <= 1).mean():.1%}, 과소 판정(실제보다 낮게) {under}장, 과대 판정 {int((g_pred > g_true).sum())}장\n" + cm.to_string())
print(" 창정비를 야전·현장으로 낮추는 과소 판정이 가장 위험하므로 경계 부근은 신뢰도로 걸러 검사관이 확인한다.")
# ---------------- 3) 판정 신뢰도 지수 보정 ----------------
mud_te = np.array([mud_est(im) for im in ite])
conf = confidence(P, idx, Fte, mud_te)
bins = pd.cut(conf, [0, 70, 80, 90, 101], labels=["70 미만", "70~80", "80~90", "90 이상"])
cal = pd.DataFrame({"bin": bins, "ok": g_true == g_pred, "near": np.abs(g_true - g_pred) <= 1}).groupby("bin", observed=True).agg(건수=("ok", "size"), 일치율=("ok", "mean"), 한단계이내=("near", "mean"))
print("\n[3] 신뢰도 지수 구간별 실제 일치율(높은 신뢰도일수록 높아야 한다)\n" + cal.round(3).to_string())
hi = conf >= 80
print(f" 신뢰도 80 이상 자동 통과 {hi.sum()}장({hi.mean():.0%}): 일치율 {(g_true == g_pred)[hi].mean():.1%} / 80 미만 {(~hi).sum()}장은 검사관 확인 대상(일치율 {(g_true == g_pred)[~hi].mean():.1%})")
# ---------------- 4) 불규칙 상황: 저조도, 진흙 가림, 작은 결함 ----------------
rows = []
for name, kw in [("정상 조명", dict(seed=2)), ("저조도", dict(seed=7, dark=True)), ("진흙 20% 가림", dict(seed=8, mud=0.2)), ("진흙 35% 가림", dict(seed=9, mud=0.35))]:
mm, ii = (mte, ite) if name == "정상 조명" else make_images(150, **kw)[:2]
FF = Fte if name == "정상 조명" else feature_table(ii)
c2, P2, i2, _ = predict_pipeline(pl, FF)
cf = confidence(P2, i2, FF, [mud_est(im) for im in ii])
yy = mm["damage_idx"].values
gg, gp = mm["grade"].values, grade_of(i2)
rows.append((name, len(mm), np.mean(np.abs(yy - np.clip(base.predict(hist_feat(ii)), 0, 100))), np.mean(np.abs(yy - i2)), (gg == gp).mean(), cf.mean(), (gg == gp)[cf >= 80].mean() if (cf >= 80).any() else np.nan, (cf >= 80).mean()))
ir = pd.DataFrame(rows, columns=["조건", "장수", "기준 MAE", "제안 MAE", "등급 일치율", "평균 신뢰도", "신뢰도 80 이상 일치율", "80 이상 비율"])
print("\n[4] 불규칙 상황별 성능(- 는 해당 사진 없음)\n" + ir.round(2).to_string(index=False, na_rep="-"))
print(" 진흙 가림이 커지면 오차와 과소 판정이 늘고 평균 신뢰도가 떨어진다. 이런 사진은 재촬영 요구 또는 검사관 확인으로 보낸다.")
print(" 등급은 정비 소요의 1차 판정이며, 최종 정비 등급은 검사관이 확정한다(M7). 사진은 교육용 가상 자료이다.")
```
실행 결과 예시
```text
[1] 손상도 점수 예측 성능(평가 300장)
기준(밝기 히스토그램 회귀) 제안(분할 특징+유형 확률)
MAE 12.107 3.223
RMSE 15.045 6.759
R2 0.732 0.946
MAE 개선 73.4%, RMSE 개선 55.1% (목표 20% 이상: 달성), R2 0.946 (목표 0.85 이상: 달성)
[2] 정비 등급 판정: 일치율 93.3%, 한 단계 이내 99.7%, 과소 판정(실제보다 낮게) 16장, 과대 판정 4장
판정 현장정비 판정 야전정비 판정 창정비
실제 현장정비 182 2 0
실제 야전정비 11 47 2
실제 창정비 1 4 51
창정비를 야전·현장으로 낮추는 과소 판정이 가장 위험하므로 경계 부근은 신뢰도로 걸러 검사관이 확인한다.
[3] 신뢰도 지수 구간별 실제 일치율(높은 신뢰도일수록 높아야 한다)
건수 일치율 한단계이내
bin
70 미만 35 0.543 1.000
70~80 31 0.871 0.968
80~90 80 1.000 1.000
90 이상 154 1.000 1.000
신뢰도 80 이상 자동 통과 234장(78%): 일치율 100.0% / 80 미만 66장은 검사관 확인 대상(일치율 69.7%)
[4] 불규칙 상황별 성능(- 는 해당 사진 없음)
조건 장수 기준 MAE 제안 MAE 등급 일치율 평균 신뢰도 신뢰도 80 이상 일치율 80 이상 비율
정상 조명 300 12.11 3.22 0.93 86.19 1.0 0.78
저조도 150 13.17 5.52 0.93 74.60 1.0 0.45
진흙 20% 가림 150 53.72 23.89 0.45 61.91 - 0.00
진흙 35% 가림 150 66.17 19.41 0.57 59.80 - 0.00
진흙 가림이 커지면 오차와 과소 판정이 늘고 평균 신뢰도가 떨어진다. 이런 사진은 재촬영 요구 또는 검사관 확인으로 보낸다.
등급은 정비 소요의 1차 판정이며, 최종 정비 등급은 검사관이 확정한다(M7). 사진은 교육용 가상 자료이다.
```
## 미션 6. 장비 결함 사진 업로드 및 손상 부위 시각화 판정 인터페이스 구현
- 난이도: 중급~고급 / 예상 소요시간: 3.0시간
- 코드 설명: 장비 결함 사진을 업로드하면 결함 유형·면적·깊이·손상도·정비 등급·신뢰도와 손상 부위(박스·윤곽)를 시각화해 보여 주는 판정 인터페이스를 구현하고, 판정 건마다 근거 변수군 기여도 상위 3개를 자동 산출해 자연어 설명으로 제공한다(자동 확정 없이 검사 대기로 등록).
```python
"""장비 결함 사진을 업로드하면 결함 유형·면적·깊이·손상도·정비 등급·신뢰도와 손상 부위(박스·윤곽)를 시각화해 보여 주는 판정 인터페이스를 구현하고, 판정 건마다 근거 변수군 기여도 상위 3개를 자동 산출해 자연어 설명으로 제공한다(자동 확정 없이 검사 대기로 등록)."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor
from sklearn.model_selection import cross_val_predict
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----
def fit_pipeline(meta, F):
"""결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다."""
clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)
P = cross_val_predict(clf, F, meta["cls"], cv=4, method="predict_proba")
clf.fit(F, meta["cls"])
reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta["damage_idx"])
return {"clf": clf, "reg": reg, "cols": list(F.columns)}
def predict_pipeline(pl, F):
X = F[pl["cols"]]
P = pl["clf"].predict_proba(X)
idx = np.clip(pl["reg"].predict(np.c_[X.values, P]), 0, 100)
return pl["clf"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)
# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----
def mud_est(img):
"""진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다."""
sm = ndi.uniform_filter(img, size=(3, 3, 1))
s = sm.sum(axis=2) + 1e-6
rr, gg = sm[..., 0] / s, sm[..., 1] / s
return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())
def confidence(P, idx, F, mud):
"""판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다."""
bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))
qual = np.clip(1.0 - np.clip(0.55 - F["gain"].values, 0, None) * 2 - np.clip(0.9 - F["c_mean"].values, 0, None), 0, 1)
c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual
return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)
mtr, itr, _ = make_images(600, seed=1)
Ftr = feature_table(itr)
pl = fit_pipeline(mtr, Ftr)
reg_p = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.07, random_state=0)
Xd = np.c_[Ftr[pl["cols"]].values, cross_val_predict(RandomForestClassifier(n_estimators=80, random_state=0, n_jobs=1), Ftr, mtr["cls"], cv=3, method="predict_proba")]
dm = mtr["depth_mm"].values > 0.2
depth_model = reg_p.fit(Xd[dm], mtr["depth_mm"].values[dm])
MM2 = 1.5 ** 2 # 1화소 = 1.5mm 가정 -> 면적 mm2
GROUPS = {"결함 면적·모양": ["area_px", "area_frac", "n_comp", "big_px", "elong", "fill", "thin", "cy", "cx"], "명암 대비": ["dark_frac", "c_mean", "c_std", "c_min", "in_dark", "ring_contrast"],
"붉은 갈색 색상": ["rust_frac", "chroma_mean", "chroma_max", "in_chroma", "sat"], "가장자리·테두리": ["edge_mean", "edge_p95", "rim_frac"], "조명 밝기": ["gain"]}
MEAN = Ftr.mean()
def contrib(F):
"""변수군을 평균값으로 대체했을 때 손상도 점수 변화(양수 = 그 변수군이 손상도를 높인 요인). 사진 여러 장을 한 번에 계산한다."""
full = predict_pipeline(pl, F)[2]
out = {}
for g, cols in GROUPS.items():
x = F.copy(); x[cols] = MEAN[cols].values
out[g] = full - predict_pipeline(pl, x)[2]
return pd.DataFrame(out, index=F.index)
def top3(F):
return contrib(F).apply(lambda r: r.reindex(r.abs().sort_values(ascending=False).index)[:3].to_dict(), axis=1)
def inspect(img, pid):
"""업로드 사진 한 장을 판정한다. 결과는 항상 '검사 대기'이며 자동 확정하지 않는다."""
F = feature_table([img])
cls, P, idx, _ = predict_pipeline(pl, F)
mud = mud_est(img)
conf = float(confidence(P, idx, F, [mud])[0])
m = segment(img)[0]
lab, n = ndi.label(m)
box = None
if n:
k = int(np.argmax(ndi.sum(m, lab, range(1, n + 1)))) + 1
sl = ndi.find_objects(lab)[k - 1]
box = (sl[1].start, sl[0].start, sl[1].stop - sl[1].start, sl[0].stop - sl[0].start)
dep = float(np.clip(depth_model.predict(np.c_[F[pl["cols"]].values, P])[0], 0, None)) if cls[0] in ("crack", "corrosion", "damage") else 0.0
if mud >= 0.03 or F["gain"].iloc[0] < 0.17:
state = "재촬영 요구(오염·저조도)"
elif conf >= 80:
state = "검사 대기(신뢰도 높음, 검사관 확인 후 확정)"
else:
state = "검사 대기(신뢰도 낮음, 검사관 정밀 확인)"
return {"id": pid, "cls": cls[0], "area_mm2": float(F["area_px"].iloc[0] * MM2), "depth": dep, "idx": float(idx[0]), "grade": int(grade_of(idx[0])), "conf": conf, "mud": mud, "box": box, "mask": m, "state": state, "F": F}
# ---------------- 1) 업로드 시뮬레이션: 조건이 다른 사진 8장 ----------------
mt, it, kt = make_images(300, seed=2)
mud_m, imud, kmud = make_images(30, seed=11, mud=0.2)
dark_m, idk, kdk = make_images(30, seed=12, dark=True)
picks = [("U01", it[next(i for i in range(300) if mt.cls[i] == "crack")], kt, mt, "crack"), ("U02", it[next(i for i in range(300) if mt.cls[i] == "corrosion")], kt, mt, "corrosion"),
("U03", it[next(i for i in range(300) if mt.cls[i] == "damage")], kt, mt, "damage"), ("U04", it[next(i for i in range(300) if mt.cls[i] == "leak")], kt, mt, "leak"),
("U05", it[next(i for i in range(300) if mt.cls[i] == "none")], kt, mt, "none"), ("U06", idk[0], kdk, dark_m, None), ("U07", imud[0], kmud, mud_m, None), ("U08", imud[1], kmud, mud_m, None)]
res = []
for pid, img, _, _, _ in picks:
res.append(inspect(img, pid))
true_rows = [mt.loc[next(i for i in range(300) if mt.cls[i] == c)] for c in ("crack", "corrosion", "damage", "leak", "none")] + [dark_m.loc[0], mud_m.loc[0], mud_m.loc[1]]
tb = pd.DataFrame([{"사진": r["id"], "판정 유형": KO_CLS[r["cls"]], "실제 유형": KO_CLS[t["cls"]], "면적(mm2)": r["area_mm2"], "깊이(mm)": r["depth"], "손상도": r["idx"], "실제 손상도": t["damage_idx"],
"등급": GRADE_NAME[r["grade"]], "실제 등급": GRADE_NAME[int(t["grade"])], "신뢰도": r["conf"], "오염": r["mud"]} for r, t in zip(res, true_rows)])
print("[1] 업로드 판정 결과(8장, 면적은 1화소 1.5mm 가정) [교육용가상자료]")
print(tb.round(2).to_string(index=False))
for r in res:
print(f" {r['id']}: {r['state']}")
# ---------------- 2) 기여도 상위 3개 자동 산출(평가 150장) ----------------
mte, ite, _ = make_images(300, seed=2)
Fte = feature_table(ite)
T3 = top3(Fte)
ok = int(sum(len(t) == 3 and all(np.isfinite(v) for v in t.values()) for t in T3))
texts = list(T3)
print(f"\n[2] 평가 {len(ite)}장 중 기여도 상위 3개 요인 자동 산출 {ok}건 ({ok / len(ite):.0%}, 목표 100%)")
avg = pd.DataFrame(texts).abs().mean().sort_values(ascending=False)
print(" 변수군별 평균 기여 크기(손상도 점):", ", ".join(f"{k} {v:.1f}" for k, v in avg.items()))
def explain(r):
t3 = top3(r["F"]).iloc[0]
why = {"결함 면적·모양": ("결함 면적·모양이 평균보다 커서", "결함 면적·모양이 평균보다 작아"), "명암 대비": ("표면과 크게 다른 어두운 영역이 있어", "어두운 영역이 적어"),
"붉은 갈색 색상": ("붉은 갈색 부식 색상이 넓어", "붉은 갈색 색상이 적어"), "가장자리·테두리": ("뚜렷한 테두리가 있어", "테두리 단서가 약해"), "조명 밝기": ("조명 밝기 영향으로", "조명 밝기 영향으로")}
main = list(t3)[0]
return f"{r['id']}: {why[main][t3[main] < 0]} 손상도 {r['idx']:.0f}점({GRADE_NAME[r['grade']]}) 판정. 주요 요인 " + ", ".join(f"{g}({v:+.1f}점)" for g, v in t3.items())
print("[3] 판정 근거 자연어 설명(결함 있는 3장)")
for r in (res[0], res[1], res[2]):
print(" -", explain(r))
# ---------------- 3) 손상 부위 시각화 정확도: 박스와 정답 마스크 겹침 ----------------
ious = []
for r, (pid, img, kk, mm, _) in zip(res[:4], picks[:4]):
i = next(j for j in range(300) if mt.cls[j] == {"U01": "crack", "U02": "corrosion", "U03": "damage", "U04": "leak"}[pid])
gt = kk[i]
ious.append((pid, (r["mask"] & gt).sum() / max((r["mask"] | gt).sum(), 1)))
print("[4] 손상 부위 표시와 정답 마스크의 IoU:", ", ".join(f"{p} {v:.2f}" for p, v in ious))
print(" 화면은 자동 확정 버튼 없이 '검사 대기 등록'만 제공하며, 최종 정비 등급은 검사관이 확정한다(M7).")
fig, ax = plt.subplots(2, 4, figsize=(14, 7))
for a, (pid, img, _, _, _), r in zip(ax.ravel(), picks, res):
a.imshow(img)
a.contour(r["mask"], levels=[0.5], colors="yellow", linewidths=1)
if r["box"]:
x, y, w, h = r["box"]; a.add_patch(plt.Rectangle((x - .5, y - .5), w, h, fill=False, ec="red", lw=1.5))
a.set_title(f"{pid} {r['cls']} idx {r['idx']:.0f} grade {r['grade']} conf {r['conf']:.0f}", fontsize=9); a.axis("off")
plt.suptitle("Upload inspection view (synthetic photos): yellow = defect region, red = box"); plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[1] 업로드 판정 결과(8장, 면적은 1화소 1.5mm 가정) [교육용가상자료]
사진 판정 유형 실제 유형 면적(mm2) 깊이(mm) 손상도 실제 손상도 등급 실제 등급 신뢰도 오염
U01 균열 균열 121.50 1.67 19.39 25.53 현장정비 현장정비 91.72 0.00
U02 부식 부식 171.00 0.44 24.12 24.49 현장정비 현장정비 93.58 0.00
U03 파손 파손 857.25 4.91 82.32 85.72 창정비 창정비 96.30 0.00
U04 누유 누유 265.50 0.00 14.31 14.60 현장정비 현장정비 88.68 0.00
U05 결함 없음 결함 없음 0.00 0.00 1.17 0.00 현장정비 현장정비 91.61 0.00
U06 누유 누유 231.75 0.00 15.84 10.12 현장정비 현장정비 86.90 0.00
U07 파손 결함 없음 103.50 3.21 70.75 1.96 창정비 현장정비 64.69 0.02
U08 파손 파손 339.75 4.17 57.16 66.53 야전정비 창정비 72.90 0.02
U01: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)
U02: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)
U03: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)
U04: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)
U05: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)
U06: 재촬영 요구(오염·저조도)
U07: 검사 대기(신뢰도 낮음, 검사관 정밀 확인)
U08: 검사 대기(신뢰도 낮음, 검사관 정밀 확인)
[2] 평가 300장 중 기여도 상위 3개 요인 자동 산출 300건 (100%, 목표 100%)
변수군별 평균 기여 크기(손상도 점): 결함 면적·모양 25.1, 붉은 갈색 색상 6.3, 명암 대비 5.3, 조명 밝기 2.6, 가장자리·테두리 1.0
[3] 판정 근거 자연어 설명(결함 있는 3장)
- U01: 결함 면적·모양이 평균보다 작아 손상도 19점(현장정비) 판정. 주요 요인 결함 면적·모양(-18.2점), 명암 대비(-5.0점), 붉은 갈색 색상(-1.0점)
- U02: 결함 면적·모양이 평균보다 작아 손상도 24점(현장정비) 판정. 주요 요인 결함 면적·모양(-11.6점), 명암 대비(-1.8점), 붉은 갈색 색상(+1.7점)
- U03: 결함 면적·모양이 평균보다 커서 손상도 82점(창정비) 판정. 주요 요인 결함 면적·모양(+23.9점), 붉은 갈색 색상(+19.6점), 명암 대비(+4.7점)
[4] 손상 부위 표시와 정답 마스크의 IoU: U01 0.95, U02 0.64, U03 0.80, U04 0.98
화면은 자동 확정 버튼 없이 '검사 대기 등록'만 제공하며, 최종 정비 등급은 검사관이 확정한다(M7).
```
## 미션 7. 외관 판정 결과에 대한 검사관 수동 보정 및 최종 정비 등급 HITL 승인 절차 설계
- 난이도: 고급 / 예상 소요시간: 3.5시간
- 코드 설명: AI 정비 등급 판정을 신뢰도에 따라 간이 승인·정밀 판독으로 나누는 검사 운영안을 비용으로 최적화하고(전수 정밀 판독 관행 대비, 검사관 일일 한도 제약 반영), 모든 판정을 검사관 수동 보정 후 정비감독관(창정비·보정 건은 품질보증관까지)이 직접 승인하는 HITL 절차(권한·사유·해시 체인 감사 로그)를 구현한다.
```python
"""AI 정비 등급 판정을 신뢰도에 따라 간이 승인·정밀 판독으로 나누는 검사 운영안을 비용으로 최적화하고(전수 정밀 판독 관행 대비, 검사관 일일 한도 제약 반영), 모든 판정을 검사관 수동 보정 후 정비감독관(창정비·보정 건은 품질보증관까지)이 직접 승인하는 HITL 절차(권한·사유·해시 체인 감사 로그)를 구현한다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor
from sklearn.model_selection import cross_val_predict
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----
def fit_pipeline(meta, F):
"""결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다."""
clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)
P = cross_val_predict(clf, F, meta["cls"], cv=4, method="predict_proba")
clf.fit(F, meta["cls"])
reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta["damage_idx"])
return {"clf": clf, "reg": reg, "cols": list(F.columns)}
def predict_pipeline(pl, F):
X = F[pl["cols"]]
P = pl["clf"].predict_proba(X)
idx = np.clip(pl["reg"].predict(np.c_[X.values, P]), 0, 100)
return pl["clf"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)
# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----
def mud_est(img):
"""진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다."""
sm = ndi.uniform_filter(img, size=(3, 3, 1))
s = sm.sum(axis=2) + 1e-6
rr, gg = sm[..., 0] / s, sm[..., 1] / s
return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())
def confidence(P, idx, F, mud):
"""판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다."""
bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))
qual = np.clip(1.0 - np.clip(0.55 - F["gain"].values, 0, None) * 2 - np.clip(0.9 - F["c_mean"].values, 0, None), 0, 1)
c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual
return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)
mtr, itr, _ = make_images(600, seed=1)
Ftr = feature_table(itr)
pl = fit_pipeline(mtr, Ftr)
def judge(seed, n, **kw):
"""사진 n장을 만들어 AI 판정(등급·신뢰도)과 정답 등급을 돌려준다."""
m, im, _ = make_images(n, seed=seed, **kw)
F = feature_table(im)
cls, P, idx, _ = predict_pipeline(pl, F)
conf = confidence(P, idx, F, [mud_est(x) for x in im])
return pd.DataFrame({"true": m["grade"].values, "ai": grade_of(idx), "conf": conf, "idx": idx, "cls": cls, "id": m["img_id"].values})
SETS = {"평상(정상 조명)": judge(2, 300), "저조도 사진 많음": judge(7, 150, dark=True), "진흙 가림 20%": judge(8, 150, mud=0.2)}
# ---------------- 1) 비용 모델(교육용 가정, 만원/장) ----------------
C_FULL, C_QUICK, C_AI = 0.9, 0.25, 0.05 # 정밀 판독, 간이 승인(AI 판정 열람 후 승인), AI 판정 전산 비용
P_HUMAN, P_CATCH = 0.08, 0.5 # 검사관 정밀 판독 오류율(+-1 등급), 간이 승인 시 AI 오판을 걸러내는 확률
LOSS_UNDER, LOSS_OVER = 20.0, 6.0 # 과소 판정(재입고·고장 위험) / 과대 판정(과다 정비) 1등급당 손실
CAP = 120 # 검사관 일일 정밀 판독 한도(장)
def loss(true, grade):
d = grade - true
return np.where(d < 0, -d * LOSS_UNDER, d * LOSS_OVER)
def operate(df, T, cap=CAP, seed=0):
"""신뢰도 T 이상은 간이 승인, 미만은 정밀 판독(검사관 보정). 정밀 판독 한도를 넘으면 신뢰도가 낮은 순서대로만 정밀 판독한다."""
rng = np.random.default_rng(seed)
n = len(df)
full = (df["conf"] < T).values
if full.sum() > cap:
order = np.argsort(df["conf"].values)
full = np.zeros(n, bool); full[order[:cap]] = True
final = df["ai"].values.copy()
err = rng.random(n) < P_HUMAN
human = np.clip(df["true"].values + np.where(err, rng.choice([-1, 1], n), 0), 1, 3)
final[full] = human[full]
wrong_ai = (df["ai"].values != df["true"].values) & ~full
caught = wrong_ai & (rng.random(n) < P_CATCH)
final[caught] = df["true"].values[caught]
cost = full.sum() * C_FULL + (~full).sum() * C_QUICK + n * C_AI + loss(df["true"].values, final).sum()
return cost, full.mean(), (final == df["true"].values).mean(), int(((final < df["true"].values)).sum())
def practice(df, seed=0):
"""관행: 모든 사진을 검사관이 정밀 판독(한도 없음, 검사관 오류율 P_HUMAN)."""
rng = np.random.default_rng(seed)
err = rng.random(len(df)) < P_HUMAN
final = np.clip(df["true"].values + np.where(err, rng.choice([-1, 1], len(df)), 0), 1, 3)
return len(df) * C_FULL + loss(df["true"].values, final).sum(), (final == df["true"].values).mean()
rows = []
for name, df in SETS.items():
cp, ap = practice(df)
co, fr, ao, un = operate(df, 80)
ai_only = len(df) * C_AI + loss(df["true"].values, df["ai"].values).sum()
rows.append((name, len(df), cp, ai_only, co, 1 - co / cp, fr, ap, ao, un))
res = pd.DataFrame(rows, columns=["시나리오", "장수", "관행 전수 판독 비용", "AI 단독 비용(금지안)", "HITL 비용(T=80)", "절감률", "정밀 판독 비율", "관행 일치율", "HITL 일치율", "HITL 과소 판정"])
print("[1] 시나리오별 검사 운영 비용(가정 단가, 만원)")
print(res.assign(절감률=res["절감률"].map("{:.1%}".format), **{"정밀 판독 비율": res["정밀 판독 비율"].map("{:.0%}".format), "관행 일치율": res["관행 일치율"].map("{:.1%}".format), "HITL 일치율": res["HITL 일치율"].map("{:.1%}".format)}).round(1).to_string(index=False))
r0 = res.iloc[0]
passed = [f"{r['시나리오']} {r['절감률']:.1%}" for _, r in res.iterrows() if r["절감률"] >= .15]
print(f" 평상 절감 {r0['절감률']:.1%} (목표 15% 이상: {'달성' if r0['절감률'] >= .15 else '미달'}), 15% 이상 달성 시나리오: {', '.join(passed)}")
print(f" AI 단독 비용은 평상 {r0['AI 단독 비용(금지안)']:.0f}, 진흙 {res.iloc[2]['AI 단독 비용(금지안)']:.0f}로 오염 사진에서 과소 판정 손실이 폭증하므로 허용하지 않는다(모든 판정은 사람이 승인). 진흙 20%는 정밀 판독이 늘어 절감이 나지 않는다(미달).")
# ---------------- 2) 신뢰도 기준 T 탐색과 검사관 한도 ----------------
sw = []
for T in (60, 70, 80, 90, 95):
c, fr, a, un = operate(SETS["평상(정상 조명)"], T)
sw.append((T, c, fr, a, un))
sw = pd.DataFrame(sw, columns=["기준 T", "비용", "정밀 판독 비율", "일치율", "과소 판정"])
print("\n[2] 신뢰도 기준 T 민감도(평상 300장)\n" + sw.round(3).to_string(index=False))
best = sw.loc[sw["비용"].idxmin()]
print(f" 비용 최소 기준 T={int(best['기준 T'])}. 기준을 너무 높이면 정밀 판독이 늘어 비용이 오르고, 너무 낮으면 AI 오판이 통과한다.")
cp, _ = practice(SETS["진흙 가림 20%"])
for cap in (150, 60, 20):
c, fr, a, un = operate(SETS["진흙 가림 20%"], 80, cap=cap)
print(f" 진흙 20% 시나리오, 검사관 한도 {cap}장/일: 비용 {c:.0f} (관행 대비 절감 {1 - c / cp:+.1%}), 정밀 판독 {fr:.0%}, 일치율 {a:.1%}, 과소 판정 {un}장")
print(" 검사관 한도가 줄면 신뢰도 낮은 사진까지 간이 승인으로 넘어가 AI 오판이 걸러지지 않아 손실이 커진다(비용 +). 오염 사진은 재촬영·사전 세척 안내로 줄여야 한다.")
# ---------------- 3) HITL 결재 워크플로우 ----------------
USERS = {"ai_bot": "AI", "ins_kim": "INS", "ins_lee": "INS", "sup_park": "SUP", "qa_choi": "QA", "aud_han": "AUD"}
PERM = {"AI": {"submit"}, "INS": {"review", "correct", "reject"}, "SUP": {"approve", "reject"}, "QA": {"approve", "reject"}, "AUD": {"read"}}
class Flow:
def __init__(self):
self.docs, self.log, self.blocked = {}, [], []
def _audit(self, user, action, doc, detail):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
rec = {"seq": len(self.log) + 1, "user": user, "action": action, "doc": doc, "detail": detail, "prev": prev}
rec["hash"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]
self.log.append(rec)
def verify(self):
prev = "GENESIS"
for r in self.log:
body = {k: v for k, v in r.items() if k != "hash"}
if r["prev"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12] != r["hash"]:
return False, r["seq"]
prev = r["hash"]
return True, None
def deny(self, user, act, doc, why):
self.blocked.append((user, act, doc, why)); self._audit(user, "DENIED:" + act, doc, why); return False
def submit(self, user, pid, grade, conf, parent=None):
if "submit" not in PERM.get(USERS.get(user), set()):
return self.deny(user, "submit", "-", "AI 판정 등록 권한 없음")
did = f"INS{len(self.docs) + 1:02d}"
self.docs[did] = {"photo": pid, "ai": grade, "grade": grade, "conf": conf, "status": "검사대기", "line": ["INS", "SUP"], "step": 0, "done": [], "parent": parent}
self._audit(user, "submit", did, f"{pid} AI 등급 {GRADE_NAME[grade]} 신뢰도 {conf:.0f}")
return did
def decide(self, user, did, act, reason="", new_grade=None):
role, d = USERS.get(user), self.docs.get(did)
if role is None: return self.deny(user, act, did, "미등록 사용자")
if d is None or d["status"] in ("확정", "반려"): return self.deny(user, act, did, "결재 대상 아님(처리 완료)")
if act not in PERM[role]: return self.deny(user, act, did, f"{role} 역할은 {act} 불가")
if d["line"][d["step"]] != role: return self.deny(user, act, did, "결재 순서 위반")
if user in d["done"]: return self.deny(user, act, did, "기결재자 재결재 금지")
if len(reason.strip()) < 10: return self.deny(user, act, did, "사유 10자 이상 필수")
if act == "reject":
d["status"] = "반려"
else:
if act == "correct":
d["grade"] = new_grade
if new_grade != d["ai"] and "QA" not in d["line"]:
d["line"] = d["line"] + ["QA"] # 검사관이 AI 판정을 고치면 품질보증관까지 결재
if d["grade"] == 3 and "QA" not in d["line"]:
d["line"] = d["line"] + ["QA"] # 창정비 판정은 품질보증관까지 결재
d["done"].append(user); d["step"] += 1
d["status"] = "확정" if d["step"] == len(d["line"]) else ("감독대기" if d["step"] >= 1 else "검사대기")
self._audit(user, act, did, reason + (f" -> {GRADE_NAME[new_grade]}" if new_grade else "")); return True
def edit(self, user, did):
if self.docs[did]["status"] == "확정": return self.deny(user, "edit", did, "확정 후 등급 변경 금지(재검사 등록)")
return True
base = SETS["평상(정상 조명)"]
mix = pd.concat([base[base.conf >= 80].head(3), base[base.conf < 80].head(4), base[base.ai == 3].head(2)]).drop_duplicates("id").head(8).reset_index(drop=True)
wf = Flow()
ids = [wf.submit("ai_bot", r.id, int(r.ai), r.conf) for r in mix.itertuples()]
print(f"\n[3] AI 판정 {len(ids)}건 전부 '검사대기' 등록, 자동 확정 {sum(wf.docs[i]['status'] == '확정' for i in ids)}건 (목표 0건)")
low = [i for i, r in zip(ids, mix.itertuples()) if r.conf < 80][0]
first = ids[0]
wf.decide("ins_kim", first, "review", "AI 판정 열람, 사진과 일치 확인함")
wf.decide("sup_park", first, "approve", "검사관 의견 확인, 등급 확정")
wf.decide("ins_lee", low, "correct", "경계 부근 결함, 박스 재측정 후 등급 조정", new_grade=min(3, wf.docs[low]["ai"] + 1))
wf.decide("sup_park", low, "approve", "보정 사유 타당, 품질보증 상신")
wf.decide("qa_choi", low, "approve", "보정 근거 사진 대조 완료")
rj = ids[2]
wf.decide("ins_kim", rj, "reject", "오염 사진으로 판독 불가, 재촬영 요구")
new = wf.submit("ai_bot", mix.id[2], int(mix.ai[2]), float(mix.conf[2]), parent=rj)
tgt = ids[-1]
attacks = [("ai_bot", tgt, "review", "AI 계정이 직접 검토 처리함"), ("sup_park", tgt, "approve", "검사관 검토 없이 감독관이 승인함"), ("aud_han", tgt, "approve", "감사 계정으로 승인 시도함"),
("ghost", tgt, "review", "외부 계정으로 검토 시도함"), ("ins_kim", tgt, "review", ""), ("ins_kim", tgt, "correct", "짧음"), ("qa_choi", tgt, "approve", "품질보증관이 순서를 건너뛰어 승인"),
("ins_kim", first, "review", "이미 확정된 건을 다시 검토함"), ("sup_park", low, "reject", "확정된 건을 뒤집어 반려함")]
for u, doc, a, rs in attacks:
wf.decide(u, doc, a, rs, new_grade=2 if a == "correct" else None)
wf.edit("ins_kim", first)
print(f" 권한 우회 시도 10건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / 10:.0%}, 목표 100%)")
for u, a, doc, why in wf.blocked[:4]:
print(f" 차단 {u:<9} {a:<8} {doc}: {why}")
ok, _ = wf.verify()
snap = json.loads(json.dumps(wf.log))
wf.log[3]["detail"] = "사후에 고쳐 쓴 사유"
ok2, at = wf.verify()
wf.log = snap
print(f" 감사 로그 {len(wf.log)}건 해시 체인 무결성 {'100%' if ok else '훼손'}; 4번 기록 사후 수정 시 {'통과' if ok2 else f'위변조 검출({at}번)'}")
print(f" {low}: AI {GRADE_NAME[wf.docs[low]['ai']]} -> 검사관 보정 {GRADE_NAME[wf.docs[low]['grade']]}(보정 시 품질보증관까지 3단계 결재, 상태 {wf.docs[low]['status']}); {rj}(반려) -> {new}(재등록, parent 연결)")
print(" 문서 상태:", pd.Series({k: v["status"] for k, v in wf.docs.items()}).value_counts().to_dict(), "| 최종 정비 등급은 검사관·정비감독관의 결재로 확정되며 AI는 확정 권한이 없다.")
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
x = np.arange(len(res)); ax[0].bar(x - .2, res["관행 전수 판독 비용"], .4, label="full inspection"); ax[0].bar(x + .2, res["HITL 비용(T=80)"], .4, label="HITL (T=80)")
ax[0].set_xticks(x); ax[0].set_xticklabels([f"S{i + 1}" for i in x]); ax[0].legend(); ax[0].set_title("Total cost by scenario (10k KRW)")
ax[1].plot(sw["기준 T"], sw["비용"], "o-"); ax[1].set_title("Cost vs confidence threshold T"); ax[1].set_xlabel("T")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[1] 시나리오별 검사 운영 비용(가정 단가, 만원)
시나리오 장수 관행 전수 판독 비용 AI 단독 비용(금지안) HITL 비용(T=80) 절감률 정밀 판독 비율 관행 일치율 HITL 일치율 HITL 과소 판정
평상(정상 조명) 300 442.0 379.0 178.9 59.5% 22% 94.3% 99.0% 2
저조도 사진 많음 150 263.0 219.5 189.0 28.2% 55% 92.0% 94.7% 3
진흙 가림 20% 150 203.0 771.5 219.0 -7.9% 80% 94.0% 89.3% 0
평상 절감 59.5% (목표 15% 이상: 달성), 15% 이상 달성 시나리오: 평상(정상 조명) 59.5%, 저조도 사진 많음 28.2%
AI 단독 비용은 평상 379, 진흙 772로 오염 사진에서 과소 판정 손실이 폭증하므로 허용하지 않는다(모든 판정은 사람이 승인). 진흙 20%는 정밀 판독이 늘어 절감이 나지 않는다(미달).
[2] 신뢰도 기준 T 민감도(평상 300장)
기준 T 비용 정밀 판독 비율 일치율 과소 판정
60 267.20 0.027 0.967 8
70 158.75 0.117 0.990 2
80 178.90 0.220 0.990 2
90 238.00 0.400 0.977 2
95 238.00 0.400 0.977 2
비용 최소 기준 T=70. 기준을 너무 높이면 정밀 판독이 늘어 비용이 오르고, 너무 낮으면 AI 오판이 통과한다.
진흙 20% 시나리오, 검사관 한도 150장/일: 비용 210 (관행 대비 절감 -3.7%), 정밀 판독 100%, 일치율 94.0%, 과소 판정 1장
진흙 20% 시나리오, 검사관 한도 60장/일: 비용 300 (관행 대비 절감 -47.8%), 정밀 판독 40%, 일치율 81.3%, 과소 판정 3장
진흙 20% 시나리오, 검사관 한도 20장/일: 비용 396 (관행 대비 절감 -95.1%), 정밀 판독 13%, 일치율 75.3%, 과소 판정 7장
검사관 한도가 줄면 신뢰도 낮은 사진까지 간이 승인으로 넘어가 AI 오판이 걸러지지 않아 손실이 커진다(비용 +). 오염 사진은 재촬영·사전 세척 안내로 줄여야 한다.
[3] AI 판정 8건 전부 '검사대기' 등록, 자동 확정 0건 (목표 0건)
권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)
차단 ai_bot review INS08: AI 역할은 review 불가
차단 sup_park approve INS08: 결재 순서 위반
차단 aud_han approve INS08: AUD 역할은 approve 불가
차단 ghost review INS08: 미등록 사용자
감사 로그 25건 해시 체인 무결성 100%; 4번 기록 사후 수정 시 위변조 검출(4번)
INS04: AI 야전정비 -> 검사관 보정 창정비(보정 시 품질보증관까지 3단계 결재, 상태 확정); INS03(반려) -> INS09(재등록, parent 연결)
문서 상태: {'검사대기': 6, '확정': 2, '반려': 1} | 최종 정비 등급은 검사관·정비감독관의 결재로 확정되며 AI는 확정 권한이 없다.
```
## 미션 8. 오염물/진흙 혼입 사진 대량 판정 스트레스 테스트 및 한계치 도출
- 난이도: 고급 / 예상 소요시간: 3.0시간
- 코드 설명: 진흙·오염물 혼입 비율을 0~50%로 올린 사진 수백 장과 흐림·센서 잡음·저조도 결합 사진을 일괄 판정하는 스트레스 테스트로 오염 사진 판정 한계치(자동 처리 가능 가림 비율)와 경보 정확도, 대량 처리 시간·대시보드 로딩 시간을 측정하고 품질 게이트(재촬영·검사관 확인)의 효과를 확인한다.
```python
"""진흙·오염물 혼입 비율을 0~50%로 올린 사진 수백 장과 흐림·센서 잡음·저조도 결합 사진을 일괄 판정하는 스트레스 테스트로 오염 사진 판정 한계치(자동 처리 가능 가림 비율)와 경보 정확도, 대량 처리 시간·대시보드 로딩 시간을 측정하고 품질 게이트(재촬영·검사관 확인)의 효과를 확인한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor
from sklearn.model_selection import cross_val_predict
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----
def fit_pipeline(meta, F):
"""결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다."""
clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)
P = cross_val_predict(clf, F, meta["cls"], cv=4, method="predict_proba")
clf.fit(F, meta["cls"])
reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta["damage_idx"])
return {"clf": clf, "reg": reg, "cols": list(F.columns)}
def predict_pipeline(pl, F):
X = F[pl["cols"]]
P = pl["clf"].predict_proba(X)
idx = np.clip(pl["reg"].predict(np.c_[X.values, P]), 0, 100)
return pl["clf"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)
# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----
def mud_est(img):
"""진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다."""
sm = ndi.uniform_filter(img, size=(3, 3, 1))
s = sm.sum(axis=2) + 1e-6
rr, gg = sm[..., 0] / s, sm[..., 1] / s
return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())
def confidence(P, idx, F, mud):
"""판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다."""
bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))
qual = np.clip(1.0 - np.clip(0.55 - F["gain"].values, 0, None) * 2 - np.clip(0.9 - F["c_mean"].values, 0, None), 0, 1)
c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual
return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)
mtr, itr, _ = make_images(600, seed=1)
pl = fit_pipeline(mtr, feature_table(itr))
def run(imgs, truth):
"""사진 묶음을 판정해 게이트 적용 전·후 신뢰도와 경보(등급 2 이상 = 정비 경보)를 만든다."""
t0 = time.perf_counter()
F = feature_table(imgs)
t1 = time.perf_counter()
cls, P, idx, _ = predict_pipeline(pl, F)
t2 = time.perf_counter()
mud = np.array([mud_est(x) for x in imgs])
raw_conf = confidence(P, idx, F, np.zeros(len(imgs)))
conf = confidence(P, idx, F, mud)
t3 = time.perf_counter()
g = grade_of(idx)
return pd.DataFrame({"true": truth["grade"].values, "ai": g, "mud": mud, "raw_conf": raw_conf, "conf": conf, "idx": idx, "retake": (mud >= 0.03) | (F["gain"].values < 0.17)}), (t1 - t0, t2 - t1, t3 - t2)
# ---------------- 1) 진흙 혼입 비율 스윕 ----------------
rows, tm = [], []
for mudr in (0.0, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50):
m, im, _ = make_images(120, seed=20 + int(mudr * 100), mud=mudr)
r, t = run(im, m)
tm.append(t)
ok = r["true"] == r["ai"]
auto = (r["conf"] >= 80) & ~r["retake"] # 품질 게이트를 통과해 자동 처리(검사관 간이 승인)로 가는 사진
alarm_ok = ((r["ai"] >= 2) == (r["true"] >= 2))
ung = r["raw_conf"] >= 80
rows.append((f"{mudr:.0%}", len(r), ok.mean(), r["conf"].mean(), r["retake"].mean(), auto.mean(), alarm_ok[auto].mean() if auto.any() else np.nan, alarm_ok[ung].mean() if ung.any() else np.nan, ung.mean()))
sw = pd.DataFrame(rows, columns=["진흙 비율", "장수", "등급 일치율(전체)", "평균 신뢰도", "재촬영 요구율", "자동 처리 비율", "경보 정확도(게이트 후)", "경보 정확도(게이트 없이 80 이상)", "게이트 없이 80 이상 비율"])
print("[1] 진흙 혼입 비율별 판정(교육용 가상 사진, 비율당 120장)")
pc = lambda v: "-" if pd.isna(v) else f"{v:.0%}"
print(sw.assign(**{c: sw[c].map(pc) for c in sw.columns if c not in ("진흙 비율", "장수", "평균 신뢰도")}).round(1).to_string(index=False))
bad = sw[sw["경보 정확도(게이트 후)"] < .95]["진흙 비율"].tolist()
print(f" 경보 정확도가 95% 미만으로 처음 떨어지는 진흙 비율: {bad[0] if bad else '없음'} (진흙 0%에서는 자동 처리 {sw.iloc[0]['자동 처리 비율']:.0%}, 경보 정확도 {sw.iloc[0]['경보 정확도(게이트 후)']:.0%})")
print(f" 게이트가 없으면 진흙 20%에서도 신뢰도 80 이상이 {sw.iloc[3]['게이트 없이 80 이상 비율']:.0%} 남고 그 경보 정확도는 {sw.iloc[3]['경보 정확도(게이트 없이 80 이상)']:.0%}로 낮다. 품질 게이트는 이런 사진을 자동 처리에서 빼 검사관 확인으로 보낸다.")
# ---------------- 2) 복합 열화: 흐림, 센서 잡음, 저조도+진흙 ----------------
rng = np.random.default_rng(3)
cases = {}
m0, i0, _ = make_images(120, seed=40)
cases["정상"] = (m0, i0)
cases["흐림(초점 불량)"] = (m0, np.clip(np.stack([ndi.gaussian_filter(x, sigma=(1.8, 1.8, 0)) for x in i0]), 0, 1))
cases["센서 잡음 +0.08"] = (m0, np.clip(i0 + rng.normal(0, 0.08, i0.shape), 0, 1).astype(np.float32))
m1, i1, _ = make_images(120, seed=41, mud=0.2, dark=True)
cases["저조도+진흙 20%"] = (m1, i1)
rows = []
for name, (m, im) in cases.items():
r, _ = run(im, m)
ok = r["true"] == r["ai"]
auto = (r["conf"] >= 80) & ~r["retake"]
am = ((r["ai"] >= 2) == (r["true"] >= 2))
rows.append((name, ok.mean(), r["retake"].mean(), auto.mean(), am[auto].mean() if auto.any() else np.nan, (r["true"] > r["ai"]).mean()))
cc = pd.DataFrame(rows, columns=["조건", "등급 일치율", "재촬영 요구율", "자동 처리 비율", "자동 처리 경보 정확도", "과소 판정 비율"])
print("\n[2] 복합 열화 조건(120장)\n" + cc.assign(**{c: cc[c].map(pc) for c in cc.columns[1:]}).to_string(index=False))
print(f" 흐림 사진은 게이트가 직접 잡지 못해 자동 처리 {cc.iloc[1]['자동 처리 비율']:.0%} 중 경보 정확도 {cc.iloc[1]['자동 처리 경보 정확도']:.0%}로 95%에 못 미친다 -> 초점·촬영 거리 가이드 보완이 필요하다.")
# ---------------- 3) 대량 처리 시간과 대시보드 로딩 ----------------
tt = np.array(tm).mean(axis=0) / 120
per = tt.sum()
print(f"\n[3] 사진 1장당 처리 시간(1개 코어 가정): 분할·특징 {tt[0] * 1000:.1f}ms, 판정 {tt[1] * 1000:.2f}ms, 오염 추정·신뢰도 {tt[2] * 1000:.1f}ms, 합계 {per * 1000:.1f}ms ({1 / per:.0f}장/초)")
for n in (1000, 5000, 20000):
print(f" {n:>6}장 일괄 판정 예상: {n * per:.0f}초 ({n * per / 60:.1f}분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다")
def dashboard(df):
t0 = time.perf_counter()
summ = df.groupby("ai").agg(건수=("idx", "size"), 평균신뢰도=("conf", "mean"))
low = df.sort_values("conf").head(20)
fg, ax = plt.subplots(1, 2, figsize=(9, 3.5))
ax[0].bar(summ.index.astype(str), summ["건수"]); ax[1].hist(df["conf"], bins=20)
fg.canvas.draw(); plt.close(fg)
return time.perf_counter() - t0, len(low)
base, _ = run(cases["정상"][1], cases["정상"][0])
loads = []
for n in (300, 3000, 30000, 300000):
big = pd.concat([base] * (n // len(base) + 1), ignore_index=True).head(n)
loads.append((n, dashboard(big)[0]))
print(" 대시보드 로딩 시간(요약 집계 + 상위 20개 목록 + 차트 렌더링):", ", ".join(f"{n}건 {s:.2f}초" for n, s in loads))
print(f" 3000건 기준 로딩 {loads[1][1]:.2f}초(목표 2초 이내: {'달성' if loads[1][1] <= 2 else '미달'}), 30만 건에서도 {loads[3][1]:.2f}초. 화면은 요약과 신뢰도 낮은 사진 우선 목록만 먼저 표시한다.")
# ---------------- 4) 결론 ----------------
print("\n[4] 한계치 요약")
print(f" - 자동 처리(검사관 간이 승인) 후보는 가림이 없는 사진 중심이며, 진흙 {bad[0] if bad else '5%'} 이상부터 경보 정확도 95% 목표를 지키지 못해 재촬영 또는 검사관 정밀 판독으로 보낸다")
print(f" - 정상 사진도 {1 - sw.iloc[0]['자동 처리 비율']:.0%}는 신뢰도 80 미만이라 검사관 확인 대상이다. 경보 정확도는 자동 처리 사진에 한해 판단하고 자동 처리 비율을 함께 보고한다")
print(" - 판정·경보는 검사관 확인의 보조 수단이며 최종 정비 등급은 사람이 확정한다. 모든 사진은 교육용 가상 자료이다.")
fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))
x = np.arange(len(sw))
ax[0].plot(x, sw["등급 일치율(전체)"] * 100, "o-", label="grade accuracy (all)"); ax[0].plot(x, sw["재촬영 요구율"] * 100, "s-", label="retake rate"); ax[0].plot(x, sw["자동 처리 비율"] * 100, "^-", label="auto-processed")
ax[0].set_xticks(x); ax[0].set_xticklabels(sw["진흙 비율"]); ax[0].set_xlabel("mud ratio"); ax[0].legend(); ax[0].set_title("Mud stress sweep (%)")
ax[1].bar(range(len(cc)), cc["자동 처리 비율"] * 100); ax[1].set_xticks(range(len(cc))); ax[1].set_xticklabels(["clean", "blur", "noise", "dark+mud"]); ax[1].set_title("Auto-processed share (%)")
ax[2].loglog([l[0] for l in loads], [l[1] for l in loads], "o-"); ax[2].axhline(2, c="r", ls="--"); ax[2].set_title("Dashboard load time (s) vs records")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[1] 진흙 혼입 비율별 판정(교육용 가상 사진, 비율당 120장)
진흙 비율 장수 등급 일치율(전체) 평균 신뢰도 재촬영 요구율 자동 처리 비율 경보 정확도(게이트 후) 경보 정확도(게이트 없이 80 이상) 게이트 없이 80 이상 비율
0% 120 90% 86.4 0% 73% 100% 100% 73%
5% 120 65% 67.3 1% 12% 93% 94% 26%
10% 120 53% 63.3 12% 3% 75% 88% 27%
20% 120 42% 63.9 66% 0% - 73% 25%
30% 120 64% 62.1 88% 0% - 77% 11%
40% 120 59% 59.8 99% 0% - 38% 7%
50% 120 68% 64.9 100% 0% - 79% 16%
경보 정확도가 95% 미만으로 처음 떨어지는 진흙 비율: 5% (진흙 0%에서는 자동 처리 73%, 경보 정확도 100%)
게이트가 없으면 진흙 20%에서도 신뢰도 80 이상이 25% 남고 그 경보 정확도는 73%로 낮다. 품질 게이트는 이런 사진을 자동 처리에서 빼 검사관 확인으로 보낸다.
[2] 복합 열화 조건(120장)
조건 등급 일치율 재촬영 요구율 자동 처리 비율 자동 처리 경보 정확도 과소 판정 비율
정상 96% 0% 75% 100% 4%
흐림(초점 불량) 84% 0% 62% 91% 11%
센서 잡음 +0.08 66% 8% 8% 100% 8%
저조도+진흙 20% 42% 100% 0% - 12%
흐림 사진은 게이트가 직접 잡지 못해 자동 처리 62% 중 경보 정확도 91%로 95%에 못 미친다 -> 초점·촬영 거리 가이드 보완이 필요하다.
[3] 사진 1장당 처리 시간(1개 코어 가정): 분할·특징 4.1ms, 판정 0.16ms, 오염 추정·신뢰도 0.3ms, 합계 4.5ms (223장/초)
1000장 일괄 판정 예상: 4초 (0.1분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다
5000장 일괄 판정 예상: 22초 (0.4분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다
20000장 일괄 판정 예상: 90초 (1.5분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다
대시보드 로딩 시간(요약 집계 + 상위 20개 목록 + 차트 렌더링): 300건 0.19초, 3000건 0.10초, 30000건 0.14초, 300000건 0.17초
3000건 기준 로딩 0.10초(목표 2초 이내: 달성), 30만 건에서도 0.17초. 화면은 요약과 신뢰도 낮은 사진 우선 목록만 먼저 표시한다.
[4] 한계치 요약
- 자동 처리(검사관 간이 승인) 후보는 가림이 없는 사진 중심이며, 진흙 5% 이상부터 경보 정확도 95% 목표를 지키지 못해 재촬영 또는 검사관 정밀 판독으로 보낸다
- 정상 사진도 27%는 신뢰도 80 미만이라 검사관 확인 대상이다. 경보 정확도는 자동 처리 사진에 한해 판단하고 자동 처리 비율을 함께 보고한다
- 판정·경보는 검사관 확인의 보조 수단이며 최종 정비 등급은 사람이 확정한다. 모든 사진은 교육용 가상 자료이다.
```
## 미션 9. AAR 기반 외관 오판 사례 분석 및 컴퓨터 비전 모델 파라미터 재보정
- 난이도: 고급 / 예상 소요시간: 3.0시간
- 코드 설명: 운영 기간 외관 판정 로그로 AAR을 수행해 오판 사례를 원인별(오염 가림·저조도·작은 결함·경계 근처·유형 혼동)로 분류하고, 분할 임계값·학습 데이터·등급 경계(과소 판정 손실 반영)를 재보정해 보류 구간에서 개선을 검증한 뒤 비용 절감액·시간 단축률·투자 회수 기간 3대 효과 지표를 산식과 민감도로 산출한다.
```python
"""운영 기간 외관 판정 로그로 AAR을 수행해 오판 사례를 원인별(오염 가림·저조도·작은 결함·경계 근처·유형 혼동)로 분류하고, 분할 임계값·학습 데이터·등급 경계(과소 판정 손실 반영)를 재보정해 보류 구간에서 개선을 검증한 뒤 비용 절감액·시간 단축률·투자 회수 기간 3대 효과 지표를 산식과 민감도로 산출한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor
from sklearn.model_selection import cross_val_predict
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----
def fit_pipeline(meta, F):
"""결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다."""
clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)
P = cross_val_predict(clf, F, meta["cls"], cv=4, method="predict_proba")
clf.fit(F, meta["cls"])
reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta["damage_idx"])
return {"clf": clf, "reg": reg, "cols": list(F.columns)}
def predict_pipeline(pl, F):
X = F[pl["cols"]]
P = pl["clf"].predict_proba(X)
idx = np.clip(pl["reg"].predict(np.c_[X.values, P]), 0, 100)
return pl["clf"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)
# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----
def mud_est(img):
"""진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다."""
sm = ndi.uniform_filter(img, size=(3, 3, 1))
s = sm.sum(axis=2) + 1e-6
rr, gg = sm[..., 0] / s, sm[..., 1] / s
return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())
def confidence(P, idx, F, mud):
"""판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다."""
bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))
qual = np.clip(1.0 - np.clip(0.55 - F["gain"].values, 0, None) * 2 - np.clip(0.9 - F["c_mean"].values, 0, None), 0, 1)
c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual
return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)
mtr, itr, _ = make_images(600, seed=1)
Ftr = feature_table(itr)
pl0 = fit_pipeline(mtr, Ftr)
LOSS_UNDER, LOSS_OVER = 20.0, 6.0 # 과소/과대 판정 1등급당 손실(만원, 가정)
def ops(seed, n):
"""운영 사진 묶음: 저조도 1/3 + 진흙 5% 1/3 + 정상 1/3 (학습 때와 분포가 다르다)."""
parts = [make_images(n // 3, seed=seed, dark=True), make_images(n // 3, seed=seed + 1, mud=0.05), make_images(n // 3, seed=seed + 2)]
return pd.concat([p[0] for p in parts], ignore_index=True), np.concatenate([p[1] for p in parts]), np.concatenate([p[2] for p in parts])
def loss(true, g):
d = np.asarray(g) - np.asarray(true)
return np.where(d < 0, -d * LOSS_UNDER, d * LOSS_OVER)
def grade_shift(idx, delta):
"""과소 판정을 줄이기 위해 등급 경계를 delta만큼 낮춘다(35 -> 35-delta, 65 -> 65-delta)."""
return np.where(np.asarray(idx) >= 65 - delta, 3, np.where(np.asarray(idx) >= 35 - delta, 2, 1))
def evaluate(pl, F, m, ims, delta=0):
cls, P, idx, _ = predict_pipeline(pl, F)
mud = np.array([mud_est(x) for x in ims])
conf = confidence(P, idx, F, mud)
g = grade_shift(idx, delta)
auto = (conf >= 80) & (mud < 0.03) & (F["gain"].values >= 0.17)
ok = g == m["grade"].values
return {"cls": cls, "idx": idx, "g": g, "mud": mud, "conf": conf, "ok": ok, "mae": float(np.mean(np.abs(idx - m["damage_idx"].values))), "under": int((g < m["grade"].values).sum()),
"loss": float(loss(m["grade"].values, g).mean()), "auto_n": int(auto.sum()), "auto_acc": float(ok[auto].mean()) if auto.any() else np.nan}
# ---------------- 1) AAR: 운영 로그 분석(이동 구간 A, 360장) ----------------
mA, iA, kA = ops(60, 360)
rng = np.random.default_rng(0)
perm = rng.permutation(len(mA))
FA = feature_table(iA)
ev = evaluate(pl0, FA, mA, iA)
print(f"[1] AAR 대상 운영 사진 {len(mA)}장(저조도·진흙 5%·정상 혼합): 등급 일치율 {ev['ok'].mean():.1%}, 손상도 MAE {ev['mae']:.2f}, 과소 판정 {ev['under']}장, 장당 오판 손실 {ev['loss']:.2f}만원")
wrong = np.where(~ev["ok"])[0]
def cause(i):
if ev["mud"][i] >= 0.01: return "오염 가림"
if FA["gain"].iloc[i] < 0.2: return "저조도"
if mA["cls"][i] != "none" and mA["area_frac"][i] < 0.02: return "작은 결함 누락·과소"
if ev["cls"][i] != mA["cls"][i]: return "결함 유형 혼동"
if min(abs(ev["idx"][i] - 35), abs(ev["idx"][i] - 65)) < 6: return "등급 경계 근처"
return "기타"
cs = pd.Series([cause(i) for i in wrong]).value_counts()
print(f" 오판 {len(wrong)}장의 원인 분류:", ", ".join(f"{k} {v}장({v / len(wrong):.0%})" for k, v in cs.items()))
print(f" 오판 중 과소 판정(실제보다 낮은 등급) {ev['under']}장 / 과대 {int((ev['g'] > mA['grade'].values).sum())}장 -> 과소 판정이 손실 단가가 {LOSS_UNDER / LOSS_OVER:.1f}배 높아 우선 교정 대상")
# ---------------- 2) 재보정: 분할 임계값 -> 운영 데이터 재학습 -> 등급 경계 이동 ----------------
A1, A2 = perm[:240], perm[240:]
cache = [correct(iA[i]) for i in A1]
def mask_from(c, chroma, dt, rt, rim=1.45):
dark, rust, r = c < dt, (chroma > rt) & (c > 0.45), c > rim
m = dark | rust | ndi.binary_dilation(r, iterations=1) & ndi.binary_dilation(dark, iterations=3)
return ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
grid = []
for dt in (0.6, 0.7, 0.75, 0.8):
for rt in (0.44, 0.5, 0.54):
iou = np.mean([(mask_from(c, ch, dt, rt) & kA[i]).sum() / max((mask_from(c, ch, dt, rt) | kA[i]).sum(), 1) for (c, ch), i in zip(cache, A1)])
grid.append((dt, rt, iou))
gd = pd.DataFrame(grid, columns=["dark 임계값", "rust 임계값", "평균 IoU"])
bst = gd.loc[gd["평균 IoU"].idxmax()]
cur = gd[(gd["dark 임계값"] == 0.7) & (gd["rust 임계값"] == 0.5)]["평균 IoU"].iloc[0]
print(f"\n[2] 분할 임계값 재보정(운영 사진 240장 IoU): 현행(0.70, 0.50) {cur:.3f} -> 최적({bst['dark 임계값']:.2f}, {bst['rust 임계값']:.2f}) {bst['평균 IoU']:.3f}")
SEGP["dark"], SEGP["rust"] = float(bst["dark 임계값"]), float(bst["rust 임계값"])
FA2 = feature_table(iA)
mm = pd.concat([mtr, mA.iloc[A1]], ignore_index=True)
pl1 = fit_pipeline(mm, pd.concat([Ftr, FA2.iloc[A1]], ignore_index=True)) # 운영 분포 사진 240장을 추가 학습
ev2 = evaluate(pl1, FA2.iloc[A2].reset_index(drop=True), mA.iloc[A2].reset_index(drop=True), iA[A2])
costs = []
for dlt in (0, 2, 4, 6, 8):
r = evaluate(pl1, FA2.iloc[A2].reset_index(drop=True), mA.iloc[A2].reset_index(drop=True), iA[A2], delta=dlt)
costs.append((dlt, r["loss"], r["under"], r["ok"].mean()))
cd = pd.DataFrame(costs, columns=["경계 이동 delta", "장당 손실(만원)", "과소 판정", "일치율"])
best_d = int(cd.loc[cd["장당 손실(만원)"].idxmin(), "경계 이동 delta"])
print(" 등급 경계 이동(보정용 120장, 과소 판정 손실 반영)\n" + cd.round(3).to_string(index=False))
if best_d == 0:
print(" 경계를 낮추면 과대 판정이 늘어 손실이 오히려 커지므로 경계 이동은 채택하지 않는다(delta=0 유지). 과소 판정은 경계가 아니라 오염 가림에서 주로 발생한다.")
else:
print(f" 손실이 가장 작은 경계 이동 delta={best_d}점 채택(과소 판정을 줄이는 방향이며 사람 승인 후 적용)")
# ---------------- 3) 보류 구간 B(360장, 재보정에 쓰지 않음) 검증 ----------------
mB, iB, kB = ops(70, 360)
SEGP["dark"], SEGP["rust"] = 0.7, 0.5
FB0 = feature_table(iB)
e0 = evaluate(pl0, FB0, mB, iB)
SEGP["dark"], SEGP["rust"] = float(bst["dark 임계값"]), float(bst["rust 임계값"])
FB1 = feature_table(iB)
e1 = evaluate(pl1, FB1, mB, iB, delta=best_d)
tab = pd.DataFrame({"재보정 전": [e0["ok"].mean(), e0["mae"], e0["under"], e0["loss"], e0["auto_n"], e0["auto_acc"]], "재보정 후": [e1["ok"].mean(), e1["mae"], e1["under"], e1["loss"], e1["auto_n"], e1["auto_acc"]]},
index=["등급 일치율", "손상도 MAE", "과소 판정(장)", "장당 오판 손실(만원)", "자동 처리 건수", "자동 처리 일치율"])
print("\n[3] 보류 구간 B 검증\n" + tab.round(3).to_string())
print(f" 오판 손실 {1 - e1['loss'] / e0['loss']:.1%} 감소, 과소 판정 {e0['under']} -> {e1['under']}장. 오염 가림 사진은 정보 자체가 가려져 재보정으로 해결되지 않으므로 재촬영이 필요하다.")
# ---------------- 4) ROI 3대 지표(교육용 가정) ----------------
N_YR, MIN_FULL, MIN_QUICK = 20000, 15.0, 4.0 # 연 판정 사진 수, 정밀 판독/간이 승인 소요(분)
WAGE, INV, OPEX = 1.2, 2500.0, 150.0 # 검사관 인건비(만원/시), 도입 비용, 연 운영비(만원)
full_b, full_a = 1 - e0["auto_n"] / len(mB), 1 - e1["auto_n"] / len(mB)
def roi(loss_b, loss_a, fb, fa, n=N_YR, inv=INV):
t_b = fb * MIN_FULL + (1 - fb) * MIN_QUICK
t_a = fa * MIN_FULL + (1 - fa) * MIN_QUICK
save_loss = (loss_b - loss_a) * n
save_time = (MIN_FULL - t_a) / 60 * WAGE * n # 관행(전수 정밀 판독) 대비 판독 인건비 절감
save = save_loss + save_time - OPEX
return save, 1 - t_a / MIN_FULL, inv / max(save / 12, 1e-9), save_loss, save_time
sv, tr, pb, sl, st = roi(e0["loss"], e1["loss"], full_b, full_a)
print(f"\n[4] ROI 3대 지표 [교육용 가정: 연 {N_YR}장, 정밀 판독 {MIN_FULL:.0f}분, 간이 승인 {MIN_QUICK:.0f}분, 인건비 {WAGE}만원/시, 도입 {INV:.0f}만원, 운영 {OPEX:.0f}만원/년]")
print(f" (1) 연간 비용 절감액 {sv:,.0f}만원(오판 손실 감소 {sl:,.0f} + 판독 인건비 {st:,.0f} - 운영비) (2) 판독 시간 단축률 {tr:.1%}(관행 전수 정밀 판독 대비) (3) 투자 회수 기간 {pb:.1f}개월")
print(" 산식: 절감액 = (재보정 전 - 후 장당 오판 손실) x 연 장수 + (정밀 판독 - 평균 판독 시간)/60 x 인건비 x 연 장수 - 운영비")
print(" 장수 민감도:", "; ".join(f"{n}장 -> 절감 {roi(e0['loss'], e1['loss'], full_b, full_a, n=n)[0]:,.0f}만원, 회수 {roi(e0['loss'], e1['loss'], full_b, full_a, n=n)[2]:.0f}개월" for n in (5000, 10000, 20000, 40000)))
print(" 비관(재보정 효과 없음, 판독 시간 절감만):", f"절감 {roi(e0['loss'], e0['loss'], full_b, full_a)[0]:,.0f}만원, 회수 {roi(e0['loss'], e0['loss'], full_b, full_a)[2]:.0f}개월")
print(" 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정된 임계값·경계는 정비감독관 승인 후 적용한다.")
fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))
ax[0].bar(range(len(cs)), cs.values); ax[0].set_xticks(range(len(cs))); ax[0].set_xticklabels([f"C{i + 1}" for i in range(len(cs))]); ax[0].set_title("AAR: misjudgment causes (count)")
ax[1].plot(cd["경계 이동 delta"], cd["장당 손실(만원)"], "o-"); ax[1].set_title("Loss per photo vs grade-boundary shift"); ax[1].set_xlabel("delta")
x = np.arange(3); ax[2].bar(x - .2, [e0["ok"].mean() * 100, e0["under"], e0["loss"]], .4, label="before"); ax[2].bar(x + .2, [e1["ok"].mean() * 100, e1["under"], e1["loss"]], .4, label="after")
ax[2].set_xticks(x); ax[2].set_xticklabels(["accuracy %", "under-grade n", "loss/photo"]); ax[2].legend(); ax[2].set_title("Hold-out B: before vs after")
plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[1] AAR 대상 운영 사진 360장(저조도·진흙 5%·정상 혼합): 등급 일치율 83.1%, 손상도 MAE 8.72, 과소 판정 22장, 장당 오판 손실 1.99만원
오판 61장의 원인 분류: 오염 가림 29장(48%), 결함 유형 혼동 9장(15%), 저조도 8장(13%), 기타 8장(13%), 등급 경계 근처 7장(11%)
오판 중 과소 판정(실제보다 낮은 등급) 22장 / 과대 39장 -> 과소 판정이 손실 단가가 3.3배 높아 우선 교정 대상
[2] 분할 임계값 재보정(운영 사진 240장 IoU): 현행(0.70, 0.50) 0.495 -> 최적(0.75, 0.44) 0.528
등급 경계 이동(보정용 120장, 과소 판정 손실 반영)
경계 이동 delta 장당 손실(만원) 과소 판정 일치율
0 1.483 5 0.850
2 1.733 5 0.808
4 1.933 5 0.775
6 2.133 5 0.742
8 1.950 3 0.733
경계를 낮추면 과대 판정이 늘어 손실이 오히려 커지므로 경계 이동은 채택하지 않는다(delta=0 유지). 과소 판정은 경계가 아니라 오염 가림에서 주로 발생한다.
[3] 보류 구간 B 검증
재보정 전 재보정 후
등급 일치율 0.819 0.853
손상도 MAE 7.579 6.559
과소 판정(장) 26.000 25.000
장당 오판 손실(만원) 2.194 2.022
자동 처리 건수 150.000 151.000
자동 처리 일치율 0.980 0.980
오판 손실 7.8% 감소, 과소 판정 26 -> 25장. 오염 가림 사진은 정보 자체가 가려져 재보정으로 해결되지 않으므로 재촬영이 필요하다.
[4] ROI 3대 지표 [교육용 가정: 연 20000장, 정밀 판독 15분, 간이 승인 4분, 인건비 1.2만원/시, 도입 2500만원, 운영 150만원/년]
(1) 연간 비용 절감액 5,140만원(오판 손실 감소 3,444 + 판독 인건비 1,846 - 운영비) (2) 판독 시간 단축률 30.8%(관행 전수 정밀 판독 대비) (3) 투자 회수 기간 5.8개월
산식: 절감액 = (재보정 전 - 후 장당 오판 손실) x 연 장수 + (정밀 판독 - 평균 판독 시간)/60 x 인건비 x 연 장수 - 운영비
장수 민감도: 5000장 -> 절감 1,173만원, 회수 26개월; 10000장 -> 절감 2,495만원, 회수 12개월; 20000장 -> 절감 5,140만원, 회수 6개월; 40000장 -> 절감 10,430만원, 회수 3개월
비관(재보정 효과 없음, 판독 시간 절감만): 절감 1,696만원, 회수 18개월
가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정된 임계값·경계는 정비감독관 승인 후 적용한다.
```
## 미션 10. 장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구 통합 완성
- 난이도: 최고급 / 예상 소요시간: 3.5시간
- 코드 설명: 신규 사진 갱신, 정합성 검증, 분포 변화(PSI) 점검, 자동 재학습(챔피언/챌린저), 결함 판정·손상도·정비 등급 산출과 검사 대기·재촬영 분리, 제원 불일치 환류까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다.
```python
"""신규 사진 갱신, 정합성 검증, 분포 변화(PSI) 점검, 자동 재학습(챔피언/챌린저), 결함 판정·손상도·정비 등급 산출과 검사 대기·재촬영 분리, 제원 불일치 환류까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다."""
import importlib
import json
import sys
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_predict
# ---------------------------------------------------------------------------
# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)
# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.
# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.
# ---------------------------------------------------------------------------
from scipy import ndimage as ndi
SZ = 64
CLASSES = ["none", "crack", "corrosion", "damage", "leak"]
KO_CLS = {"none": "결함 없음", "crack": "균열", "corrosion": "부식", "damage": "파손", "leak": "누유"}
CLS_W = {"none": 0.0, "crack": 1.4, "corrosion": 1.0, "damage": 1.6, "leak": 0.6} # 손상도 계산용 결함 유형 가중치
GRADE_NAME = {1: "현장정비", 2: "야전정비", 3: "창정비"}
YY, XX = np.mgrid[0:SZ, 0:SZ]
def grade_of(idx):
return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))
def blob(rng, cy, cx, r, rough=0.35):
"""불규칙한 원형 영역 마스크."""
noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)
noise = noise / (np.abs(noise).max() + 1e-9)
return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r
def draw(rng, cls, sev):
"""결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다."""
m = np.zeros((SZ, SZ), bool)
if cls == "crack":
y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)
for _ in range(int(20 + 40 * sev)):
ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2
if 0 <= int(y) < SZ and 0 <= int(x) < SZ:
m[int(y), int(x)] = True
m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))
elif cls == "corrosion":
for _ in range(int(rng.integers(1, 4))):
m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))
elif cls == "damage":
m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)
elif cls == "leak":
x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)
w, ln = 2 + 4 * sev, 12 + 26 * sev
m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)
return m
def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):
"""가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64)."""
rng = np.random.default_rng(seed)
imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []
for i in range(n):
cls = "none" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))
sev = 0.0 if cls == "none" else float(rng.beta(1.6, 2.0))
base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)
img = base + rng.normal(0, 0.012, (SZ, SZ, 3))
m = draw(rng, cls, sev)
a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다
if cls == "crack":
col = img * rng.uniform(0.2, 0.45)
elif cls == "corrosion":
col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))
elif cls == "damage":
col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)
rim = m & ~ndi.binary_erosion(m, iterations=2)
a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)
elif cls == "leak":
col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)
else:
col = img
img = img * (1 - a) + col * a
if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐
st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)
al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)
img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al
if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)
y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)
for t in np.linspace(0, 25, 40):
yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))
if 0 <= yy < SZ and 0 <= xx < SZ:
img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)
if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)
mm = np.zeros((SZ, SZ), bool)
while mm.mean() < mud:
mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)
img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))
gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)
ang = rng.uniform(0, 2 * np.pi)
light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)
imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)
masks[i] = m
area = float(m.mean())
depth = {"none": 0.0, "crack": 0.3 + 3.0 * sev, "corrosion": 0.1 + 1.5 * sev, "damage": 1.0 + 8.0 * sev, "leak": 0.0}[cls] * rng.lognormal(0, 0.1)
idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))
rows.append({"img_id": f"IMG{seed:02d}{i:04d}", "cls": cls, "sev": sev, "area_frac": area, "depth_mm": depth, "damage_idx": idx,
"grade": int(grade_of(idx)), "gain": gain, "mud": mud})
meta = pd.DataFrame(rows)
# 장비 관리 기록(제원 요약): 사진 판정과 대조할 값
age = rng.integers(1, 25, n)
meta["day"] = np.sort(rng.integers(0, 90, n))
meta["equip_type"] = rng.choice(["vehicle", "generator", "comm", "trailer"], n, p=[0.4, 0.2, 0.2, 0.2])
meta["mfg_year"] = 2026 - age
meta["op_hours"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta["damage_idx"], 10, 20000)
meta["env"] = rng.choice(["coastal", "inland"], n, p=[0.3, 0.7])
reg = np.clip(meta["grade"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급
meta["reg_grade"] = reg
meta["emergency"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)
return meta, imgs, masks
# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----
def correct(img):
"""조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율."""
g = img.mean(axis=2)
A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]
keep = np.ones(SZ * SZ, bool)
for _ in range(3):
coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)
plane = (A @ coef).reshape(SZ, SZ)
keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()
if keep.sum() < 200:
keep[:] = True
c = g / np.maximum(plane, 1e-3)
chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)
return c, chroma
SEGP = {"dark": 0.7, "rust": 0.5, "rim": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상
def segment(img):
"""결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손)."""
c, chroma = correct(img)
dark = c < SEGP["dark"]
rust = (chroma > SEGP["rust"]) & (c > 0.45)
rim = c > SEGP["rim"]
m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)
m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m
return m, c, chroma, dark, rust, rim
def extract(img):
"""분할 결과와 색·모양에서 24개 특징을 만든다."""
m, c, chroma, dark, rust, rim = segment(img)
lab, n = ndi.label(m)
area = float(m.sum())
if n:
sizes = ndi.sum(m, lab, range(1, n + 1))
k = int(np.argmax(sizes)) + 1
sl = ndi.find_objects(lab)[k - 1]
h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
big = float(sizes.max())
elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)
else:
big, elong, fill = 0.0, 1.0, 0.0
gy, gx = np.gradient(c)
mag = np.hypot(gx, gy)
inside = m.sum() > 0
return {"area_px": area, "area_frac": area / SZ ** 2, "n_comp": float(n), "big_px": big, "elong": elong, "fill": fill,
"dark_frac": float(dark.mean()), "rust_frac": float(rust.mean()), "rim_frac": float(rim.mean()),
"c_mean": float(c.mean()), "c_std": float(c.std()), "c_min": float(c.min()), "edge_mean": float(mag.mean()), "edge_p95": float(np.quantile(mag, 0.95)),
"chroma_mean": float(chroma.mean()), "chroma_max": float(chroma.max()),
"in_dark": float(c[m].mean()) if inside else 1.0, "in_chroma": float(chroma[m].mean()) if inside else float(chroma.mean()),
"ring_contrast": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,
"thin": float(1 - fill), "gain": float(img.mean()), "sat": float((img.max(axis=2) - img.min(axis=2)).mean()),
"cy": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, "cx": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}
def feature_table(imgs):
return pd.DataFrame([extract(im) for im in imgs])
# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----
def fit_pipeline(meta, F):
"""결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다."""
clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)
P = cross_val_predict(clf, F, meta["cls"], cv=4, method="predict_proba")
clf.fit(F, meta["cls"])
reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta["damage_idx"])
return {"clf": clf, "reg": reg, "cols": list(F.columns)}
def predict_pipeline(pl, F):
X = F[pl["cols"]]
P = pl["clf"].predict_proba(X)
idx = np.clip(pl["reg"].predict(np.c_[X.values, P]), 0, 100)
return pl["clf"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)
# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----
def mud_est(img):
"""진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다."""
sm = ndi.uniform_filter(img, size=(3, 3, 1))
s = sm.sum(axis=2) + 1e-6
rr, gg = sm[..., 0] / s, sm[..., 1] / s
return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())
def confidence(P, idx, F, mud):
"""판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다."""
bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))
qual = np.clip(1.0 - np.clip(0.55 - F["gain"].values, 0, None) * 2 - np.clip(0.9 - F["c_mean"].values, 0, None), 0, 1)
c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual
return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)
libs = ["numpy", "pandas", "scipy", "sklearn", "matplotlib"]
env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)
print(f"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)}")
# ---------------- 1) 신규 사진 갱신·정합성 검증(미션 1~2 요약) ----------------
m_old, i_old, k_old = make_images(600, seed=1) # 기존 600장
parts = [make_images(80, seed=80, dark=True), make_images(80, seed=81, mud=0.05), make_images(80, seed=82)]
m_new = pd.concat([p[0] for p in parts], ignore_index=True); i_new = np.concatenate([p[1] for p in parts]); k_new = np.concatenate([p[2] for p in parts])
m_new["img_id"] = ["NEW" + x[3:] for x in m_new["img_id"]] # 신규 사진 ID 구분
meta = pd.concat([m_old, m_new], ignore_index=True); imgs = np.concatenate([i_old, i_new]); masks = np.concatenate([k_old, k_new])
chk = {"사진 ID 유일": not meta["img_id"].duplicated().any(), "결측 0(제원·라벨)": int(meta[["equip_type", "mfg_year", "op_hours", "env", "reg_grade"]].isna().sum().sum()) == 0,
"화소 범위 0~1": bool((imgs.min() >= 0) and (imgs.max() <= 1)), "해상도 64x64x3": imgs.shape[1:] == (64, 64, 3), "제조연도 가능 범위": bool(meta["mfg_year"].between(1990, 2026).all()),
"마스크 면적 = 라벨 면적": bool(np.allclose(masks.mean(axis=(1, 2)), meta["area_frac"], atol=1e-6)), "손상도-등급 일치": bool((grade_of(meta["damage_idx"]) == meta["grade"]).all())}
print(f"[1] 신규 240장 포함 {len(meta)}장 정합성 검증 {sum(chk.values())}/{len(chk)} 통과, 긴급 점검 사진 {int(meta['emergency'].sum())}장 보존")
F = feature_table(imgs)
F["mud"] = [mud_est(x) for x in imgs]
n_old = len(m_old)
# ---------------- 2) 분포 변화 점검(PSI) 및 자동 재학습 ----------------
def psi(a, b, bins=8):
q = np.unique(np.quantile(a, np.linspace(0, 1, bins + 1)))
q[0], q[-1] = -np.inf, np.inf
pa, pb = np.histogram(a, q)[0] / len(a) + 1e-4, np.histogram(b, q)[0] / len(b) + 1e-4
return float(np.sum((pa - pb) * np.log(pa / pb)))
drift = {c: psi(F[c][:n_old], F[c][n_old:]) for c in ["gain", "c_mean", "c_std", "rust_frac", "area_frac", "mud"]}
worst = max(drift, key=drift.get)
print("[2] 분포 변화 PSI(기존 600 vs 신규 240):", ", ".join(f"{k} {v:.2f}" for k, v in drift.items()), f"-> 가장 큰 변화 {worst} (0.2 이상이면 재학습 검토)")
rng = np.random.default_rng(0)
new_idx = rng.permutation(np.arange(n_old, len(meta)))
tr_new, hold = new_idx[:160], new_idx[160:] # 신규 중 160장은 재학습용, 80장은 검증용
cols = [c for c in F.columns if c != "mud"]
def fit_on(ix):
return fit_pipeline(meta.iloc[ix].reset_index(drop=True), F.iloc[ix][cols].reset_index(drop=True))
def score(pl, ix):
cls, P, idx, _ = predict_pipeline(pl, F.iloc[ix][cols])
y = meta["damage_idx"].values[ix]
return float(np.mean(np.abs(y - idx))), float(np.mean(grade_of(idx) == meta["grade"].values[ix]))
champ = fit_on(np.arange(n_old))
chall = fit_on(np.r_[np.arange(n_old), tr_new])
a, b = score(champ, hold), score(chall, hold)
promote = (a[0] - b[0]) / a[0] >= 0.03
drifted = max(drift.values()) >= 0.2
print(f" 검증 80장(신규 분포): 챔피언 MAE {a[0]:.2f}·등급 일치 {a[1]:.1%} vs 챌린저 MAE {b[0]:.2f}·등급 일치 {b[1]:.1%} -> {'챌린저 승격' if promote else '기존 모델 유지'}(MAE 3% 이상 개선일 때만 승격)")
model = chall if promote else champ
# ---------------- 3) 신규 사진 판정과 검사 대기 분리 ----------------
Xn = F.iloc[n_old:][cols].reset_index(drop=True)
cls, P, idx, _ = predict_pipeline(model, Xn)
mud = F["mud"].values[n_old:]
conf = confidence(P, idx, Xn, mud)
res = pd.DataFrame({"사진": meta["img_id"].values[n_old:], "판정 유형": [KO_CLS[c] for c in cls], "손상도": idx, "AI 등급": grade_of(idx), "신뢰도": conf, "오염": mud, "기록 등급": meta["reg_grade"].values[n_old:], "실제 등급": meta["grade"].values[n_old:]})
res["처리"] = np.select([(mud >= 0.03) | (Xn["gain"].values < 0.17), conf >= 80], ["재촬영 요구", "검사 대기(간이 승인 후보)"], "검사 대기(정밀 판독)")
res["제원 신호"] = np.abs(res["AI 등급"] - res["기록 등급"]) >= 2
print(f"\n[3] 신규 {len(res)}장 판정 결과 [교육용가상자료] (상위 6건)")
print(res.head(6)[["사진", "판정 유형", "손상도", "AI 등급", "신뢰도", "오염", "처리"]].round(2).to_string(index=False))
vc = res["처리"].value_counts()
print(" 처리 구분:", vc.to_dict(), "/ 자동 확정 0건(모든 판정은 검사관 확인과 정비감독관 결재 필요)")
auto = res["처리"].str.contains("간이")
print(f" 간이 승인 후보 {int(auto.sum())}장의 등급 정답 일치율 {(res['AI 등급'] == res['실제 등급'])[auto].mean():.1%}, 전체 일치율 {(res['AI 등급'] == res['실제 등급']).mean():.1%}")
print(f" 제원 환류: AI 등급과 관리 기록 등급이 2단계 이상 다른 {int(res['제원 신호'].sum())}장은 제원 수정 요청 후보(자동 수정 금지, 담당자 확인)")
# ---------------- 4) 운영 문서 완비율·인터페이스 명세 ----------------
docs = {"데이터 사전·스키마(M1)": 24, "정제 규칙 11종(M2)": 11, "Baseline·EDA(M3)": 8, "분할·손상도 모델(M4)": 24, "등급·신뢰도 엔진(M5)": 3, "판정 인터페이스·설명(M6)": 5,
"승인 절차·권한(M7)": 4, "오염 스트레스·한계치(M8)": 7, "AAR·ROI(M9)": 4, "운영·재학습 절차(M10)": 6, "장애 대응": 5, "인터페이스 가이드": 4}
done = sum(v > 0 for v in docs.values())
iface = {"input": {"photo": ["img_id:str", "image:64x64x3 float", "equip_type:str", "reg_grade:int"], "format": "image+json"},
"output": {"result": ["img_id", "defect", "damage_idx", "grade", "confidence", "mud", "state"], "approval": "PENDING_ONLY"}, "auth": "role-based(가상)", "note": "상위 군수정보체계는 가상 인터페이스로만 정의"}
print(f"\n[4] 운영 문서 {len(docs)}개 항목 중 작성 완료 {done}개 ({done / len(docs):.0%}) / 인터페이스 명세(JSON):")
print(" ", json.dumps(iface, ensure_ascii=False))
# ---------------- 5) 자체 점검·브리핑 ----------------
ev_ix = np.arange(n_old, len(meta))
y = meta["damage_idx"].values[ev_ix]
fin_mae = float(np.mean(np.abs(y - idx)))
big = y >= 10
fin_mape = float(np.mean(np.abs(y[big] - idx[big]) / y[big]) * 100)
base = LinearRegression().fit(F.iloc[:n_old][["area_px"]], meta["damage_idx"][:n_old])
base_mae = float(np.mean(np.abs(y - np.clip(base.predict(F.iloc[n_old:][["area_px"]]), 0, 100))))
checks = [("환경 점검 통과", env_ok == len(libs)), ("데이터 정합성 전 항목 통과", all(chk.values())), ("재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)", a[0] > 0 and b[0] > 0),
("손상도 MAPE 25% 이하(점수 10 이상)", fin_mape <= 25), ("면적 기준 모델 대비 MAE 20% 이상 개선", fin_mae <= 0.8 * base_mae),
("자동 확정 0건(검사 대기·재촬영 분리)", bool(res["처리"].str.contains("검사 대기|재촬영").all())), ("오염 의심 사진 전부 재촬영 또는 정밀 판독으로 분리", bool(res.loc[res["오염"] >= 0.01, "처리"].ne("검사 대기(간이 승인 후보)").all())),
("운영 문서 100% 완비", done == len(docs))]
print("\n[5] 자체 점검")
for n, ok in checks:
print(f" [{'PASS' if ok else 'FAIL'}] {n}")
print("\n[6] 브리핑 요약")
print(f" - 신규 사진 240장으로 분포 변화를 점검({worst} PSI {drift[worst]:.2f}, {'재학습 검토 수준' if drifted else '경미'})하고 재학습 판단을 자동 수행했다({'챌린저 승격' if promote else '기존 모델 유지'}).")
print(f" - 신규 사진 손상도 MAE {base_mae:.1f}(면적 기준) -> {fin_mae:.1f}, MAPE {fin_mape:.1f}%. 처리 구분: " + ", ".join(f"{k} {v}장" for k, v in vc.items()) + ".")
print(" - 최종 정비 등급은 검사관·정비감독관 결재로 확정하며 AI 판정은 자동 확정되지 않는다. 오염 사진은 재촬영을 요구했다.")
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
ax[0].bar(list(drift), list(drift.values())); ax[0].axhline(0.2, c="r", ls="--"); ax[0].set_title("Feature drift (PSI), old vs new photos"); ax[0].tick_params(axis="x", rotation=30)
ax[1].scatter(y, idx, s=8, c=np.where(res["처리"] == "재촬영 요구", "r", "g")); ax[1].plot([0, 100], [0, 100], "k--"); ax[1].set_title("New photos: damage index true vs predicted (red = retake)")
plt.suptitle("QLT03 integrated pipeline (synthetic photos)"); plt.tight_layout(); plt.show()
```
실행 결과 예시
```text
[0] 환경 점검: Python 3.11, 필수 모듈 5/5
[1] 신규 240장 포함 840장 정합성 검증 7/7 통과, 긴급 점검 사진 38장 보존
[2] 분포 변화 PSI(기존 600 vs 신규 240): gain 0.56, c_mean 0.10, c_std 0.26, rust_frac 0.39, area_frac 0.07, mud 0.79 -> 가장 큰 변화 mud (0.2 이상이면 재학습 검토)
검증 80장(신규 분포): 챔피언 MAE 8.79·등급 일치 82.5% vs 챌린저 MAE 6.86·등급 일치 86.2% -> 챌린저 승격(MAE 3% 이상 개선일 때만 승격)
[3] 신규 240장 판정 결과 [교육용가상자료] (상위 6건)
사진 판정 유형 손상도 AI 등급 신뢰도 오염 처리
NEW800000 균열 34.85 1 53.85 0.0 재촬영 요구
NEW800001 부식 30.58 1 72.79 0.0 검사 대기(정밀 판독)
NEW800002 결함 없음 1.25 1 87.85 0.0 검사 대기(간이 승인 후보)
NEW800003 결함 없음 9.30 1 86.00 0.0 검사 대기(간이 승인 후보)
NEW800004 부식 41.78 2 81.09 0.0 재촬영 요구
NEW800005 부식 36.30 2 58.39 0.0 재촬영 요구
처리 구분: {'검사 대기(간이 승인 후보)': 112, '검사 대기(정밀 판독)': 86, '재촬영 요구': 42} / 자동 확정 0건(모든 판정은 검사관 확인과 정비감독관 결재 필요)
간이 승인 후보 112장의 등급 정답 일치율 99.1%, 전체 일치율 90.4%
제원 환류: AI 등급과 관리 기록 등급이 2단계 이상 다른 19장은 제원 수정 요청 후보(자동 수정 금지, 담당자 확인)
[4] 운영 문서 12개 항목 중 작성 완료 12개 (100%) / 인터페이스 명세(JSON):
{"input": {"photo": ["img_id:str", "image:64x64x3 float", "equip_type:str", "reg_grade:int"], "format": "image+json"}, "output": {"result": ["img_id", "defect", "damage_idx", "grade", "confidence", "mud", "state"], "approval": "PENDING_ONLY"}, "auth": "role-based(가상)", "note": "상위 군수정보체계는 가상 인터페이스로만 정의"}
[5] 자체 점검
[PASS] 환경 점검 통과
[PASS] 데이터 정합성 전 항목 통과
[PASS] 재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)
[PASS] 손상도 MAPE 25% 이하(점수 10 이상)
[PASS] 면적 기준 모델 대비 MAE 20% 이상 개선
[PASS] 자동 확정 0건(검사 대기·재촬영 분리)
[PASS] 오염 의심 사진 전부 재촬영 또는 정밀 판독으로 분리
[PASS] 운영 문서 100% 완비
[6] 브리핑 요약
- 신규 사진 240장으로 분포 변화를 점검(mud PSI 0.79, 재학습 검토 수준)하고 재학습 판단을 자동 수행했다(챌린저 승격).
- 신규 사진 손상도 MAE 13.0(면적 기준) -> 4.5, MAPE 10.6%. 처리 구분: 검사 대기(간이 승인 후보) 112장, 검사 대기(정밀 판독) 86장, 재촬영 요구 42장.
- 최종 정비 등급은 검사관·정비감독관 결재로 확정하며 AI 판정은 자동 확정되지 않는다. 오염 사진은 재촬영을 요구했다.
```