289 KiB
289 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋의 라벨링 기준, 사진·장비 제원(관리 기록)·결함 라벨·판정 결과의 스키마와 데이터 딕셔너리(피처 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예측 대상: 결함 면적, 추정 깊이, 손상도 점수, 정비 등급. 제원 활용: 사진 판정과 관리 기록 등급이 크게 다르면 제원 이상 신호로 표시")
In [ ]:
"""해상도·밝기·표기가 제각각인 장비 외관 사진과 제원 기록, 결함 영역(바운딩 박스) 라벨의 가상 원천 자료에 조명 보정·해상도 표준화·박스 정리를 포함한 정제 규칙 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()
In [ ]:
"""밝기 히스토그램과 평균 색만 쓰는 외관 결함 유무 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종 이상: 달성)")
In [ ]:
"""분할(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()
In [ ]:
"""손상도 점수 회귀와 결함 유형 확률로 정비 등급(현장정비·야전정비·창정비)을 자동 분류하고, 분류 확률·등급 경계까지의 거리·사진 품질로 판정 신뢰도 지수(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). 사진은 교육용 가상 자료이다.")
In [ ]:
"""장비 결함 사진을 업로드하면 결함 유형·면적·깊이·손상도·정비 등급·신뢰도와 손상 부위(박스·윤곽)를 시각화해 보여 주는 판정 인터페이스를 구현하고, 판정 건마다 근거 변수군 기여도 상위 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()
In [ ]:
"""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()
In [ ]:
"""진흙·오염물 혼입 비율을 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()
In [ ]:
"""운영 기간 외관 판정 로그로 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()
In [ ]:
"""신규 사진 갱신, 정합성 검증, 분포 변화(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()