Files
pbl02/uploads/QLT03/QLT03_student.ipynb
T
2026-10-08 17:15:05 +09:00

3898 lines
292 KiB
Plaintext

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 🎯 [QLT03] 군수품 제원정보 이상치 자동검증 Tool\n",
"### Google Colab 실습 환경 (품질·규격·검사 / Level 3)\n",
"\n",
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/QLT03/QLT03_solution.ipynb)\n",
"\n",
"---\n",
"\n",
"## 📌 1. 프로젝트 개요 및 배경\n",
"\n",
"| 항목 | 내용 |\n",
"| :--- | :--- |\n",
"| **과정 분류** | 품질·규격·검사 (Level 3) |\n",
"| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n",
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
"\n",
"### 📖 프로젝트 목표\n",
"군수장비의 외관 검사는 검사관의 눈과 경험에 크게 의존해 판정 편차가 생기고 검사 병목이 되기 쉽다. 본 프로젝트는 규격·제원 정합성과 검사 병목, 이상 신호, 설명 근거, 품질 개선 효과를 함께 익히도록, 교육용 가상 외관 사진(균열·부식·파손·누유)을 코드로 만들어 결함 판정과 손상도·정비 등급 산정을 자동화한다. 학습자는 사진 데이터셋과 제원 기록 정의, 조명 보정·해상도 표준화·박스 정제, 1차 판별 Baseline, 분할 기반 면적·깊이·손상도 모델, 정비 등급 분류와 신뢰도 지수, 사진 업로드 판정 화면과 근거 설명, 검사관 수동 보정과 결재(HITL), 진흙 혼입 스트레스 시험, AAR 재보정과 ROI, 재학습 파이프라인 통합을 차례로 수행한다. 최종 목표는 사진으로 결함과 손상도를 판정하고 사진 판정과 관리 기록의 불일치를 이상 신호로 환류하되, 최종 정비 등급은 검사관이 확정하는 AI 외관 검사 도구를 완성하는 것이다.\n",
"\n",
"> **⚠️ [교육용 가상 데이터셋 원칙]**\n",
"> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n",
"> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n",
"\n",
"---\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ============================================================================\n",
"# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n",
"# ============================================================================\n",
"!pip install -q numpy pandas matplotlib scipy scikit-learn\n",
"\n",
"import os\n",
"import sys\n",
"import time\n",
"import math\n",
"import json\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"import warnings\n",
"warnings.filterwarnings('ignore')\n",
"\n",
"# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n",
"plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n",
"plt.rcParams['axes.unicode_minus'] = False\n",
"\n",
"print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 01. 장비 외관 결함(균열, 부식, 파손, 누유) 고해상도 사진 데이터셋 및 라벨링 정의\n",
"**소요시간**: 약 1.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"장비 외관 결함(균열, 부식, 파손) 사진 데이터셋 및 라벨링을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋과 라벨링 기준을 정의하고, 검사 업무 프로세스 맵과 피처 데이터 사전을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **사진, 장비 제원(관리 기록), 결함 라벨, 판정 결과의 스키마와 허용값을 정하고 결함 유형별 라벨링 기준을 문장으로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/qlt03_logistics_domain_spec.md 및 데이터 딕셔너리` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군수 실무 업무 프로세스와의 일치성 및 분석 목표의 명확성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 업무 프로세스 맵 작성 완료, 필요 피처(Feature) 목록 20개 이상 정의\n",
"\n",
"\n",
"- 진행 기준: 도메인 분석서 및 데이터 요구명세서 승인\n",
"\n",
"\n",
"- 검증 방법: 군수 담당 실무자 인터뷰 및 기존 전산체계 데이터 구조 대조\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋의 라벨링 기준, 사진·장비 제원(관리 기록)·결함 라벨·판정 결과의 스키마와 데이터 딕셔너리(피처 20개 이상), 업무 프로세스 맵을 정의하고 가상 샘플로 제원 정합성을 검증한다.\"\"\"\n",
"import json\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"# 1) 업무 프로세스 맵: 촬영에서 정비 등급 승인·제원 환류까지\n",
"PROCESS = [(\"촬영\", \"사진 ID, 촬영일, 촬영 조건(조명·오염)\"), (\"제원 대조\", \"장비 식별코드, 제조연도, 가동시간, 기록상 정비 등급\"),\n",
" (\"전처리\", \"조명 보정, 해상도 표준화, 결함 영역(바운딩 박스)\"), (\"결함 판별\", \"결함 유형, 결함 면적, 추정 깊이\"),\n",
" (\"손상도 산정\", \"손상도 점수(0~100), 판정 신뢰도\"), (\"검사관 보정\", \"수동 보정 여부, 보정 사유\"),\n",
" (\"정비 등급 승인\", \"현장·야전·창정비 등급, 승인자, 승인 시각\"), (\"제원 환류\", \"판정 결과와 기록상 등급의 불일치, 제원 수정 요청\"), (\"사후검토(AAR)\", \"오판 원인, 파라미터 재보정 이력\")]\n",
"\n",
"# 2) 라벨링 기준(데이터 딕셔너리의 일부)\n",
"LABEL_RULES = {\"crack\": \"선형 어두운 균열, 길이 10px 이상\", \"corrosion\": \"붉은 갈색 면, 면적 12px 이상\", \"damage\": \"함몰·파단 윤곽(밝은 테두리 포함)\",\n",
" \"leak\": \"어둡고 번지는 액체 자국(아래 방향 흐름)\", \"none\": \"결함 없음(무해한 얼룩·긁힘은 결함으로 라벨하지 않음)\"}\n",
"\n",
"# 3) 스키마: 그룹 / 필드 / 자료형 / 규칙 / 설명\n",
"S = {\n",
" \"photo\": {\n",
" \"img_id\": (str, {\"pattern\": r\"^IMG\\d{6}$\"}, \"사진 ID\"),\n",
" \"shot_day\": (int, {\"min\": 0, \"max\": 400}, \"촬영일(점검 시작 후 경과일)\"),\n",
" \"width\": (int, {\"enum\": [64]}, \"표준 해상도 가로(px)\"),\n",
" \"height\": (int, {\"enum\": [64]}, \"표준 해상도 세로(px)\"),\n",
" \"channels\": (int, {\"enum\": [3]}, \"채널 수(RGB)\"),\n",
" \"brightness\": (float, {\"min\": 0.05, \"max\": 1.0}, \"평균 밝기(0~1)\"),\n",
" \"mud_ratio\": (float, {\"min\": 0, \"max\": 1}, \"진흙·오염 가림 비율(추정)\"),\n",
" },\n",
" \"spec\": {\n",
" \"equip_id\": (str, {\"pattern\": r\"^VX-\\d{4}-\\d{4}$\"}, \"장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\"),\n",
" \"equip_type\": (str, {\"enum\": [\"vehicle\", \"generator\", \"comm\", \"trailer\"]}, \"장비 구분\"),\n",
" \"mfg_year\": (int, {\"min\": 1990, \"max\": 2026}, \"제조연도(가상)\"),\n",
" \"op_hours\": (float, {\"min\": 0, \"max\": 30000}, \"누적 가동시간(h)\"),\n",
" \"env\": (str, {\"enum\": [\"coastal\", \"inland\"]}, \"운용 환경\"),\n",
" \"reg_grade\": (int, {\"enum\": [1, 2, 3]}, \"관리 기록상 직전 정비 등급(1 현장, 2 야전, 3 창정비)\"),\n",
" },\n",
" \"label\": {\n",
" \"defect_class\": (str, {\"enum\": [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]}, \"결함 유형\"),\n",
" \"bbox_x\": (int, {\"min\": 0, \"max\": 63}, \"결함 박스 좌상단 x\"),\n",
" \"bbox_y\": (int, {\"min\": 0, \"max\": 63}, \"결함 박스 좌상단 y\"),\n",
" \"bbox_w\": (int, {\"min\": 1, \"max\": 64}, \"결함 박스 폭\"),\n",
" \"bbox_h\": (int, {\"min\": 1, \"max\": 64}, \"결함 박스 높이\"),\n",
" \"emergency\": (bool, {}, \"긴급 점검 요청 여부(화질 불량이어도 보존)\"),\n",
" },\n",
" \"result\": {\n",
" \"area_frac\": (float, {\"min\": 0, \"max\": 1}, \"결함 면적 비율 - 예측대상 1\"),\n",
" \"depth_mm\": (float, {\"min\": 0, \"max\": 30}, \"추정 결함 깊이 - 예측대상 2\"),\n",
" \"damage_idx\": (float, {\"min\": 0, \"max\": 100}, \"손상도 점수 - 예측대상 3\"),\n",
" \"grade\": (int, {\"enum\": [1, 2, 3]}, \"정비 등급(현장/야전/창정비) - 예측대상 4\"),\n",
" \"confidence\": (float, {\"min\": 0, \"max\": 1}, \"판정 신뢰도 지수\"),\n",
" },\n",
"}\n",
"\n",
"\n",
"def check(name, v, typ, rule):\n",
" # [TODO : 학생 실습 1] 장비 외관 결함(균열, 부식, 파손, 누유) 고해상도 사진 데이터셋 및 라벨링 정의 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def validate(rec):\n",
" # [TODO : 학생 실습 1] 장비 외관 결함(균열, 부식, 파손, 누유) 고해상도 사진 데이터셋 및 라벨링 정의 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"rng = np.random.default_rng(1)\n",
"recs = []\n",
"for i in range(20):\n",
" g = int(rng.integers(1, 4))\n",
" recs.append({\n",
" \"photo\": {\"img_id\": f\"IMG{i:06d}\", \"shot_day\": int(rng.integers(0, 90)), \"width\": 64, \"height\": 64, \"channels\": 3,\n",
" \"brightness\": float(round(rng.uniform(0.2, 0.9), 2)), \"mud_ratio\": float(round(rng.uniform(0, 0.3), 2))},\n",
" \"spec\": {\"equip_id\": f\"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}\", \"equip_type\": str(rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"])),\n",
" \"mfg_year\": int(rng.integers(2000, 2025)), \"op_hours\": float(round(rng.uniform(100, 9000), 0)), \"env\": str(rng.choice([\"coastal\", \"inland\"])),\n",
" \"reg_grade\": int(np.clip(g + rng.integers(-1, 2), 1, 3))},\n",
" \"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)),\n",
" \"bbox_w\": int(rng.integers(5, 30)), \"bbox_h\": int(rng.integers(5, 30)), \"emergency\": bool(rng.random() < 0.05)},\n",
" \"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)),\n",
" \"grade\": g, \"confidence\": float(round(rng.uniform(0.5, 1), 2))}})\n",
"ok = sum(not validate(r) for r in recs)\n",
"print(f\"정상 샘플 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n",
"\n",
"# 5) 오류 주입 시험\n",
"bad = json.loads(json.dumps(recs[0]))\n",
"bad[\"spec\"][\"mfg_year\"] = 2085 # 제원 값 범위 이상(미래 제조연도)\n",
"bad[\"label\"][\"bbox_x\"] = 60; bad[\"label\"][\"bbox_w\"] = 20 # 박스가 사진 밖으로 나감\n",
"bad[\"result\"][\"grade\"] = 5 # 허용되지 않은 등급\n",
"del bad[\"spec\"][\"op_hours\"] # 가동시간 누락\n",
"bad[\"photo\"][\"brightness\"] = \"어두움\" # 자료형 오류\n",
"print(\"\\n[오류 주입 결과]\")\n",
"for e in validate(bad):\n",
" print(\" -\", e)\n",
"mis = json.loads(json.dumps(recs[1])); mis[\"result\"][\"grade\"] = 3; mis[\"spec\"][\"reg_grade\"] = 1\n",
"print(\"제원 불일치 시험:\", validate(mis))\n",
"\n",
"# 6) 프로세스 맵과 데이터 딕셔너리\n",
"print(\"\\n[업무 프로세스 맵]\")\n",
"print(\" -> \".join(f\"{i}.{p}\" for i, (p, _) in enumerate(PROCESS, 1)))\n",
"for p, d in PROCESS:\n",
" print(f\" {p:10s}: {d}\")\n",
"print(\"\\n[라벨링 기준]\")\n",
"for k, v in LABEL_RULES.items():\n",
" print(f\" {k:10s}: {v}\")\n",
"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()]\n",
"dd = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\", \"설명\"])\n",
"print(f\"\\n[데이터 딕셔너리] 피처 {len(dd)}개 (목표 20개 이상: {'달성' if len(dd) >= 20 else '미달'})\")\n",
"print(dd[[\"그룹\", \"필드\", \"자료형\", \"설명\"]].to_string(index=False))\n",
"print(\"\\n예측 대상: 결함 면적, 추정 깊이, 손상도 점수, 정비 등급. 제원 활용: 사진 판정과 관리 기록 등급이 크게 다르면 제원 이상 신호로 표시\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"정상 샘플 20건 중 검증 통과 20건, 오류 0건\n",
"\n",
"[오류 주입 결과]\n",
" - photo.brightness: 자료형 오류(str)\n",
" - spec.mfg_year: 최대값 2026 초과\n",
" - spec.op_hours: 필수 필드 누락\n",
" - result.grade: 허용되지 않은 값 '5'\n",
" - label.bbox: 박스가 이미지 밖으로 나감\n",
"제원 불일치 시험: ['제원 대조: 판정 등급과 관리 기록 등급이 2단계 이상 차이(제원 이상 신호)']\n",
"\n",
"[업무 프로세스 맵]\n",
"1.촬영 -> 2.제원 대조 -> 3.전처리 -> 4.결함 판별 -> 5.손상도 산정 -> 6.검사관 보정 -> 7.정비 등급 승인 -> 8.제원 환류 -> 9.사후검토(AAR)\n",
" 촬영 : 사진 ID, 촬영일, 촬영 조건(조명·오염)\n",
" 제원 대조 : 장비 식별코드, 제조연도, 가동시간, 기록상 정비 등급\n",
" 전처리 : 조명 보정, 해상도 표준화, 결함 영역(바운딩 박스)\n",
" 결함 판별 : 결함 유형, 결함 면적, 추정 깊이\n",
" 손상도 산정 : 손상도 점수(0~100), 판정 신뢰도\n",
" 검사관 보정 : 수동 보정 여부, 보정 사유\n",
" 정비 등급 승인 : 현장·야전·창정비 등급, 승인자, 승인 시각\n",
" 제원 환류 : 판정 결과와 기록상 등급의 불일치, 제원 수정 요청\n",
" 사후검토(AAR) : 오판 원인, 파라미터 재보정 이력\n",
"\n",
"[라벨링 기준]\n",
" crack : 선형 어두운 균열, 길이 10px 이상\n",
" corrosion : 붉은 갈색 면, 면적 12px 이상\n",
" damage : 함몰·파단 윤곽(밝은 테두리 포함)\n",
" leak : 어둡고 번지는 액체 자국(아래 방향 흐름)\n",
" none : 결함 없음(무해한 얼룩·긁힘은 결함으로 라벨하지 않음)\n",
"\n",
"[데이터 딕셔너리] 피처 24개 (목표 20개 이상: 달성)\n",
" 그룹 필드 자료형 설명\n",
" photo img_id str 사진 ID\n",
" photo shot_day int 촬영일(점검 시작 후 경과일)\n",
" photo width int 표준 해상도 가로(px)\n",
" photo height int 표준 해상도 세로(px)\n",
" photo channels int 채널 수(RGB)\n",
" photo brightness float 평균 밝기(0~1)\n",
" photo mud_ratio float 진흙·오염 가림 비율(추정)\n",
" spec equip_id str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\n",
" spec equip_type str 장비 구분\n",
"... (이하 생략)\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 02. 이미지 조도 보정, 해상도 표준화 및 결함 영역(Bounding Box) 전처리 모듈 구현\n",
"**소요시간**: 약 2.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"조도 보정, 해상도 표준화 및 결함 영역 바운딩 박스 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조명 보정, 해상도 표준화, 결함 영역(Bounding Box) 정제를 포함한 사진·제원 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **해상도, 밝기, 표기가 제각각인 가상 원천 사진과 제원·박스 라벨에 정제 규칙 10종 이상을 순서대로 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/etl/qlt03_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n",
"\n",
"\n",
"- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n",
"\n",
"\n",
"- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"해상도·밝기·표기가 제각각인 장비 외관 사진과 제원 기록, 결함 영역(바운딩 박스) 라벨의 가상 원천 자료에 조명 보정·해상도 표준화·박스 정리를 포함한 정제 규칙 11종을 적용하고, 긴급 점검 사진 같은 군수 예외 데이터는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.impute import KNNImputer\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 2] 이미지 조도 보정, 해상도 표준화 및 결함 영역(Bounding Box) 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 2] 이미지 조도 보정, 해상도 표준화 및 결함 영역(Bounding Box) 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"\n",
"rng = np.random.default_rng(2)\n",
"meta, imgs, masks = make_images(150, seed=2)\n",
"n0 = len(meta)\n",
"applied = []\n",
"\n",
"\n",
"def rule(name, n):\n",
" applied.append((name, int(n)))\n",
"\n",
"\n",
"def true_box(m):\n",
" ys, xs = np.where(m)\n",
" return (int(xs.min()), int(ys.min()), int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1)) if len(xs) else None\n",
"\n",
"\n",
"# ---------------- 1) 원천 자료 만들기(일부러 넣은 오류) ----------------\n",
"raw_imgs, rows = [], []\n",
"sizes = rng.choice([32, 64, 128], n0, p=[0.2, 0.55, 0.25])\n",
"dark_set = set(rng.choice(n0, 35, replace=False))\n",
"emerg = set(rng.choice(n0, 6, replace=False))\n",
"for i in range(n0):\n",
" im = imgs[i]\n",
" if sizes[i] != 64:\n",
" im = np.clip(ndi.zoom(im, (sizes[i] / 64, sizes[i] / 64, 1), order=1), 0, 1)\n",
" if i in dark_set or i in emerg:\n",
" im = im * 0.45 # 저조도 촬영\n",
" raw_imgs.append(im.astype(np.float32))\n",
" tb = true_box(masks[i]); k = sizes[i] / 64\n",
" box = None if tb is None else [tb[0] * k, tb[1] * k, tb[2] * k, tb[3] * k]\n",
" fmt = \"xywh\"\n",
" if box is not None and rng.random() < 0.4: # 일부는 xyxy 형식으로 기록\n",
" box, fmt = [box[0], box[1], box[0] + box[2], box[1] + box[3]], \"xyxy\"\n",
" 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}\",\n",
" \"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\"]),\n",
" \"w\": int(sizes[i]), \"box\": box, \"box_fmt\": fmt, \"emergency\": i in emerg})\n",
"raw = pd.DataFrame(rows)\n",
"idx = rng.choice(n0, 60, replace=False)\n",
"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]\n",
"raw.loc[a, \"img_id\"] = raw.loc[a, \"img_id\"].str.replace(\"IMG\", \"img-\") # 사진 ID 표기 불일치\n",
"raw.loc[b, \"shot\"] = [x.replace(\"-\", \"\") for x in raw.loc[b, \"shot\"]] # 촬영일 형식 혼재\n",
"raw.loc[c, \"op_hours\"] = raw.loc[c, \"op_hours\"] / 24; raw.loc[c, \"hours_unit\"] = \"day\" # 가동시간 단위 혼재\n",
"raw.loc[d, \"mfg_year\"] = rng.choice([1900, 2085], len(d)) # 불가능한 제조연도\n",
"raw.loc[e, [\"mfg_year\", \"op_hours\", \"reg_grade\"]] = np.nan # 제원 결측\n",
"for i in f: # 박스가 사진 밖으로 나감\n",
" if raw.at[i, \"box\"] is not None and raw.at[i, \"box_fmt\"] == \"xywh\":\n",
" 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]]\n",
"for i in g: # 폭 0 박스\n",
" if raw.at[i, \"box\"] is not None and raw.at[i, \"box_fmt\"] == \"xywh\":\n",
" raw.at[i, \"box\"] = [raw.at[i, \"box\"][0], raw.at[i, \"box\"][1], 0, 0]\n",
"dup_rows = raw.sample(8, random_state=1)\n",
"raw = pd.concat([raw, dup_rows], ignore_index=True)\n",
"raw_imgs += [raw_imgs[int(r[\"img_id\"][-6:])] for _, r in dup_rows.iterrows()] # 중복 사진\n",
"print(f\"원천 사진 {len(raw)}장 (중복 8장, 오류 주입 {len(idx)}건, 저조도 {len(dark_set | emerg)}장, 긴급 점검 {len(emerg)}장 포함)\")\n",
"\n",
"# ---------------- 2) 정제 규칙 11종 ----------------\n",
"df, ims = raw.copy(), list(raw_imgs)\n",
"dup = df.duplicated([\"img_id\", \"equip_id\", \"shot\"]); rule(\"R1 중복 사진 제거\", dup.sum())\n",
"keep = (~dup).values; df = df[keep].reset_index(drop=True); ims = [im for im, k in zip(ims, keep) if k]\n",
"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())\n",
"t1 = pd.to_datetime(df[\"shot\"], format=\"%Y-%m-%d\", errors=\"coerce\"); t2 = pd.to_datetime(df[\"shot\"], format=\"%Y%m%d\", errors=\"coerce\")\n",
"rule(\"R3 촬영일 형식 통일\", t1.isna().sum()); df[\"shot\"] = t1.fillna(t2)\n",
"day = df[\"hours_unit\"] == \"day\"; df.loc[day, \"op_hours\"] *= 24; df[\"hours_unit\"] = \"h\"; rule(\"R4 가동시간 단위 통일(일 -> 시간)\", day.sum())\n",
"bad = ~df[\"mfg_year\"].between(1990, 2026) & df[\"mfg_year\"].notna(); df.loc[bad, \"mfg_year\"] = np.nan; rule(\"R5 불가능 제조연도 결측 처리\", bad.sum())\n",
"nres = sum(im.shape[0] != 64 for im in ims)\n",
"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]\n",
"rule(\"R6 해상도 표준화(32/128 -> 64px)\", nres)\n",
"before_b = np.array([im.mean() for im in ims])\n",
"fixed = []\n",
"for im in ims:\n",
" cc, _ = correct(im) # 보정 밝기 = 밝기 / 조명 평면\n",
" plane = im.mean(axis=2) / np.maximum(cc, 1e-6)\n",
" fixed.append(np.clip(im * 0.45 / np.maximum(plane, 1e-3)[..., None], 0, 1)) # 조명 기울기를 펴고 정상 면 밝기를 0.45로 맞춘다\n",
"ims = fixed; rule(\"R7 조명 보정·정규화(평균 밝기 0.3 미만 사진 수)\", (before_b < 0.3).sum())\n",
"bx = []\n",
"nx = 0\n",
"for (_, r) in df.iterrows():\n",
" if r[\"box\"] is None:\n",
" bx.append(None); continue\n",
" x, y, w_, h_ = r[\"box\"]\n",
" if r[\"box_fmt\"] == \"xyxy\":\n",
" w_, h_ = w_ - x, h_ - y; nx += 1\n",
" k = 64 / r[\"w\"]\n",
" bx.append([x * k, y * k, w_ * k, h_ * k])\n",
"rule(\"R8 박스 형식 통일(xyxy -> xywh, 64px 좌표)\", nx)\n",
"clip_n, zero_n, boxes, manual = 0, 0, [], []\n",
"for i, b_ in enumerate(bx):\n",
" if b_ is None:\n",
" boxes.append(None); continue\n",
" x, y, w_, h_ = b_\n",
" x2, y2 = min(x + w_, 64), min(y + h_, 64)\n",
" if x2 > 64 or x + w_ > 64.5 or y + h_ > 64.5:\n",
" clip_n += 1\n",
" if x2 - x < 1 or y2 - y < 1:\n",
" zero_n += 1; manual.append(df.loc[i, \"img_id\"]); boxes.append(None); continue\n",
" boxes.append([int(round(x)), int(round(y)), int(round(x2 - x)), int(round(y2 - y))])\n",
"rule(\"R9 박스 좌표 클리핑·무효 박스 수동 확인 분리\", clip_n + zero_n)\n",
"miss = df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]].isna().sum().sum()\n",
"df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]] = KNNImputer(n_neighbors=5).fit_transform(df[[\"mfg_year\", \"op_hours\", \"reg_grade\", \"w\"]])[:, :3]\n",
"df[\"mfg_year\"] = df[\"mfg_year\"].round().astype(int); df[\"reg_grade\"] = df[\"reg_grade\"].round().astype(int); rule(\"R10 제원 결측 KNN 보정\", miss)\n",
"df[\"quality_flag\"] = np.array([im.std() < 0.03 for im in ims]) & ~df[\"emergency\"]\n",
"rule(\"R11 긴급 점검 사진 보존·일반 저화질 표시\", int(df[\"emergency\"].sum()))\n",
"\n",
"# ---------------- 3) 정합성 검증 ----------------\n",
"after_b = np.array([im.mean() for im in ims])\n",
"tb = {r[\"img_id\"]: true_box(masks[int(r[\"img_id\"][-6:])]) for _, r in df.iterrows()}\n",
"ious = []\n",
"for (_, r), bb in zip(df.iterrows(), boxes):\n",
" t = tb[r[\"img_id\"]]\n",
" if bb is not None and t is not None:\n",
" 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]))\n",
" ious.append(ix * iy / (bb[2] * bb[3] + t[2] * t[3] - ix * iy))\n",
"chk = {\"중복 사진 0장\": not df[\"img_id\"].duplicated().any(), \"사진 ID 형식\": df[\"img_id\"].str.match(r\"^IMG\\d{6}$\").all(), \"촬영일 변환\": df[\"shot\"].notna().all(),\n",
" \"가동시간 단위 h\": (df[\"hours_unit\"] == \"h\").all(), \"제조연도 1990~2026\": df[\"mfg_year\"].between(1990, 2026).all(),\n",
" \"모든 사진 64x64x3\": all(im.shape == (64, 64, 3) for im in ims), \"밝기 편차 감소\": after_b.std() < before_b.std() / 2,\n",
" \"박스가 사진 안\": 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),\n",
" \"제원 결측 0건\": df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]].notna().all().all(), f\"긴급 점검 {int(df['emergency'].sum())}장 보존\": int(df[\"emergency\"].sum()) == len(emerg),\n",
" \"정제 박스와 정답 박스 평균 IoU 0.9 이상\": float(np.mean(ious)) >= 0.9}\n",
"rate = np.mean(list(chk.values()))\n",
"print(\"\\n[적용 규칙과 처리 건수]\")\n",
"for nme, n in applied:\n",
" print(f\" {nme:44s} {n:4d}건\")\n",
"print(f\"\\n정합성 검증 {sum(chk.values())}/{len(chk)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'}), 규칙 {len(applied)}종 (목표 10종 이상: {'달성' if len(applied) >= 10 else '미달'})\")\n",
"for k, v in chk.items():\n",
" if not v:\n",
" print(\" 실패:\", k, \"(IoU %.2f)\" % np.mean(ious))\n",
"print(f\"정제 후 사진 {len(df)}장, 박스 수동 확인 {len(manual)}장, 긴급 점검 {int(df['emergency'].sum())}장 유지, 정제 박스 평균 IoU {np.mean(ious):.2f}\")\n",
"cmp = pd.DataFrame({\"정제 전\": [before_b.mean(), before_b.std(), raw[\"op_hours\"].mean(), raw[\"mfg_year\"].min(), raw[\"mfg_year\"].max()],\n",
" \"정제 후\": [after_b.mean(), after_b.std(), df[\"op_hours\"].mean(), df[\"mfg_year\"].min(), df[\"mfg_year\"].max()]},\n",
" index=[\"평균 밝기\", \"밝기 표준편차\", \"평균 가동시간(h)\", \"최소 제조연도\", \"최대 제조연도\"]).round(3)\n",
"print(\"\\n[정제 전후 통계]\"); print(cmp.to_string())\n",
"\n",
"fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))\n",
"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()\n",
"j = int(np.argmin(before_b))\n",
"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\")\n",
"ax[2].imshow(ims[j]); ax[2].set_title(\"after normalization\"); ax[2].axis(\"off\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"원천 사진 158장 (중복 8장, 오류 주입 60건, 저조도 40장, 긴급 점검 6장 포함)\n",
"\n",
"[적용 규칙과 처리 건수]\n",
" R1 중복 사진 제거 8건\n",
" R2 사진 ID 표기 통일 8건\n",
" R3 촬영일 형식 통일 8건\n",
" R4 가동시간 단위 통일(일 -> 시간) 8건\n",
" R5 불가능 제조연도 결측 처리 8건\n",
" R6 해상도 표준화(32/128 -> 64px) 71건\n",
" R7 조명 보정·정규화(평균 밝기 0.3 미만 사진 수) 74건\n",
" R8 박스 형식 통일(xyxy -> xywh, 64px 좌표) 54건\n",
" R9 박스 좌표 클리핑·무효 박스 수동 확인 분리 4건\n",
" R10 제원 결측 KNN 보정 32건\n",
" R11 긴급 점검 사진 보존·일반 저화질 표시 6건\n",
"\n",
"정합성 검증 12/12 통과, 통과율 100% (목표 100%: 달성), 규칙 11종 (목표 10종 이상: 달성)\n",
"정제 후 사진 150장, 박스 수동 확인 3장, 긴급 점검 6장 유지, 정제 박스 평균 IoU 0.99\n",
"\n",
"[정제 전후 통계]\n",
" 정제 전 정제 후\n",
"평균 밝기 0.293 0.447\n",
"밝기 표준편차 0.117 0.005\n",
"평균 가동시간(h) 2494.475 2609.453\n",
"최소 제조연도 1900.000 2002.000\n",
"최대 제조연도 2085.000 2025.000\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 03. 외관 결함 유무 1차 판별 Baseline 비전 모델 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"외관 결함 유무 1차 판별 Baseline 비전 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **밝기 분포와 평균 색만 쓰는 외관 결함 유무 1차 판별 Baseline 비전 모델을 구현하고 EDA로 한계와 핵심 관리 대상을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **밝기 히스토그램과 채널 평균으로 로지스틱 회귀 Baseline을 학습해 정확도·재현율을 측정하고, 작은 결함과 저조도 사진에서의 한계 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `notebooks/qlt03_eda_analysis.ipynb 및 EDA 요약 리포트` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군수 의사결정에 직관적인 인사이트 제공 수준\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: EDA 시각화 차트 8종 이상 생성, 유의미한 상관관계 피처 5개 이상 도출\n",
"\n",
"\n",
"- 진행 기준: EDA 노트북 실행 완료 및 핵심 발견점(Key Findings) 문서화\n",
"\n",
"\n",
"- 검증 방법: 통계적 가설 검정(p-value, 상관계수)을 통한 유의성 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"밝기 히스토그램과 평균 색만 쓰는 외관 결함 유무 1차 판별 Baseline 비전 모델(로지스틱 회귀)을 구현해 성능과 한계(작은 균열·조명 의존)를 확인하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·정비 공수 파레토(ABC)·제원 기록과의 불일치 분석으로 핵심 관리 대상을 찾는다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from scipy import stats\n",
"from sklearn.linear_model import LogisticRegression\n",
"from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score\n",
"from sklearn.preprocessing import StandardScaler\n",
"from sklearn.pipeline import make_pipeline\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 3] 외관 결함 유무 1차 판별 Baseline 비전 모델 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 3] 외관 결함 유무 1차 판별 Baseline 비전 모델 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"\n",
"mtr, itr, _ = make_images(400, seed=1)\n",
"mte, ite, kte = make_images(300, seed=2)\n",
"\n",
"\n",
"def hist_feat(imgs):\n",
" \"\"\"Baseline 특징: 밝기 히스토그램 16칸 + 채널 평균 3 + 밝기 표준편차 1.\"\"\"\n",
" out = []\n",
" for im in imgs:\n",
" g = im.mean(axis=2)\n",
" h, _ = np.histogram(g / max(g.max(), 1e-3), bins=16, range=(0, 1))\n",
" out.append(np.r_[h / g.size, im.mean(axis=(0, 1)) / max(g.mean(), 1e-3), g.std() / max(g.mean(), 1e-3)])\n",
" return np.array(out)\n",
"\n",
"\n",
"Htr, Hte = hist_feat(itr), hist_feat(ite)\n",
"ytr, yte = (mtr[\"cls\"] != \"none\").values, (mte[\"cls\"] != \"none\").values\n",
"base = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit(Htr, ytr)\n",
"pr = base.predict(Hte)\n",
"print(f\"[Baseline 결과] 학습 {len(mtr)}장 / 평가 {len(mte)}장, 결함 사진 {yte.mean():.1%}\")\n",
"print(f\"정확도 {accuracy_score(yte, pr):.1%}, 정밀도 {precision_score(yte, pr):.1%}, 재현율 {recall_score(yte, pr):.1%}, F1 {f1_score(yte, pr):.3f}\")\n",
"rec = pd.DataFrame({\"cls\": mte[\"cls\"], \"hit\": pr == yte}).query(\"cls != 'none'\").groupby(\"cls\")[\"hit\"].mean().round(2)\n",
"print(\"결함 유형별 재현율:\", \", \".join(f\"{KO_CLS[k]} {v:.0%}\" for k, v in rec.items()))\n",
"small = (mte[\"area_frac\"] > 0) & (mte[\"area_frac\"] < 0.02)\n",
"print(f\"면적 2% 미만 작은 결함 {int(small.sum())}장 재현율 {pr[small.values].mean():.0%} (큰 결함 {pr[(mte['area_frac'] >= 0.02).values].mean():.0%})\")\n",
"dk = mte[\"gain\"] < 0.6\n",
"print(f\"조명이 어두운 사진(gain<0.6) 정확도 {(pr == yte)[dk.values].mean():.0%} vs 밝은 사진 {(pr == yte)[~dk.values].mean():.0%}\")\n",
"print(\"-> 전체 통계만 보는 Baseline은 작은 균열을 놓치고 조명에 흔들린다. 결함 위치·모양을 보는 분할 특징이 필요하다(미션 4~5의 동기).\")\n",
"\n",
"# 1) 상관분석: 결함 유무와의 관계(점이연 상관, 유의수준 0.05) - 사진 특징 + 장비 제원 기록\n",
"Fall = pd.concat([feature_table(ite), feature_table(itr[:200])], ignore_index=True)\n",
"mall = pd.concat([mte, mtr[:200]], ignore_index=True)\n",
"Fall[\"op_hours\"], Fall[\"reg_grade\"], Fall[\"mfg_year\"] = mall[\"op_hours\"], mall[\"reg_grade\"], mall[\"mfg_year\"]\n",
"Fall[\"coastal\"] = (mall[\"env\"] == \"coastal\").astype(int)\n",
"yall = (mall[\"cls\"] != \"none\").astype(float)\n",
"rows = []\n",
"for f in [\"c_std\", \"c_min\", \"edge_p95\", \"dark_frac\", \"rust_frac\", \"sat\", \"op_hours\", \"reg_grade\", \"mfg_year\", \"coastal\"]:\n",
" r, p = stats.pearsonr(Fall[f].astype(float), yall)\n",
" rows.append({\"피처\": f, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의\": p < 0.05})\n",
"corr = pd.DataFrame(rows).sort_values(\"상관계수\", key=abs, ascending=False)\n",
"print(\"\\n[결함 유무와의 상관분석]\")\n",
"print(corr.to_string(index=False))\n",
"n_sig = int(corr[\"유의\"].sum())\n",
"print(f\"유의미한 피처 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})\")\n",
"\n",
"# 2) 제원 기록과의 불일치: 사진 판정 등급 vs 관리 기록 등급\n",
"ct = pd.crosstab(mall[\"grade\"], mall[\"reg_grade\"]).rename(index=GRADE_NAME, columns=GRADE_NAME)\n",
"gap = (mall[\"grade\"] - mall[\"reg_grade\"]).abs() >= 2\n",
"print(f\"\\n[제원 대조] 사진 등급과 관리 기록 등급이 2단계 이상 다른 장비 {int(gap.sum())}대({gap.mean():.1%}) -> 제원 이상 신호 후보\")\n",
"print(ct.to_string())\n",
"\n",
"# 3) 파레토(ABC): 정비 공수(가상)가 큰 (장비 구분, 결함 유형) 조합\n",
"HOURS = {1: 2, 2: 8, 3: 30}\n",
"mall[\"hours\"] = mall[\"grade\"].map(HOURS)\n",
"g = mall[mall[\"cls\"] != \"none\"].groupby([\"equip_type\", \"cls\"])[\"hours\"].sum().sort_values(ascending=False)\n",
"cum = g.cumsum() / g.sum()\n",
"abc = pd.cut(cum, [0, 0.8, 0.95, 1.0], labels=[\"A\", \"B\", \"C\"])\n",
"print(\"\\n[파레토 A등급(누적 정비 공수 80%)]\", \", \".join(f\"{e}/{KO_CLS[c]}\" for (e, c), v in abc.items() if v == \"A\"))\n",
"print(f\"A등급은 전체 조합 {len(g)}개 중 {int((abc == 'A').sum())}개({(abc == 'A').mean():.0%})\")\n",
"\n",
"# 4) EDA 차트 8종\n",
"fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n",
"a = ax.ravel()\n",
"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\")\n",
"mall[\"cls\"].value_counts().plot.bar(ax=a[1]); a[1].set_title(\"2. Photos by defect class\")\n",
"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\")\n",
"mall.boxplot(column=\"damage_idx\", by=\"cls\", ax=a[3], rot=30); a[3].set_title(\"4. Damage index by class\")\n",
"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\")\n",
"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)\")\n",
"a[6].barh(corr[\"피처\"], corr[\"상관계수\"]); a[6].set_title(\"7. Correlation with defect\")\n",
"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\")\n",
"plt.suptitle(\"QLT03 EDA - baseline defect screening (synthetic photos)\"); plt.tight_layout(); plt.show()\n",
"print(\"\\nEDA 차트 8종 생성 (목표 8종 이상: 달성)\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[Baseline 결과] 학습 400장 / 평가 300장, 결함 사진 76.7%\n",
"정확도 86.0%, 정밀도 89.8%, 재현율 92.2%, F1 0.910\n",
"결함 유형별 재현율: 부식 96%, 균열 75%, 파손 96%, 누유 98%\n",
"면적 2% 미만 작은 결함 31장 재현율 74% (큰 결함 95%)\n",
"조명이 어두운 사진(gain<0.6) 정확도 82% vs 밝은 사진 87%\n",
"-> 전체 통계만 보는 Baseline은 작은 균열을 놓치고 조명에 흔들린다. 결함 위치·모양을 보는 분할 특징이 필요하다(미션 4~5의 동기).\n",
"\n",
"[결함 유무와의 상관분석]\n",
" 피처 상관계수 p-value 유의\n",
" c_min -0.711 2.7e-78 True\n",
" c_std 0.496 1.8e-32 True\n",
"dark_frac 0.457 3.5e-27 True\n",
" edge_p95 0.373 5.4e-18 True\n",
"reg_grade 0.272 6.2e-10 True\n",
"rust_frac 0.242 4.5e-08 True\n",
" sat 0.186 3.0e-05 True\n",
" op_hours 0.066 1.4e-01 False\n",
" mfg_year 0.045 3.1e-01 False\n",
" coastal 0.004 9.2e-01 False\n",
"유의미한 피처 7개 (목표 5개 이상: 달성)\n",
"\n",
"[제원 대조] 사진 등급과 관리 기록 등급이 2단계 이상 다른 장비 47대(9.4%) -> 제원 이상 신호 후보\n",
"reg_grade 현장정비 야전정비 창정비\n",
"grade \n",
"현장정비 219 50 47\n",
"야전정비 8 58 28\n",
"창정비 0 15 75\n",
"\n",
"[파레토 A등급(누적 정비 공수 80%)] vehicle/파손, comm/파손, vehicle/부식, trailer/파손, generator/파손, generator/부식\n",
"A등급은 전체 조합 16개 중 6개(38%)\n",
"\n",
"EDA 차트 8종 생성 (목표 8종 이상: 달성)\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 04. 세그멘테이션(Mask R-CNN) 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델 고도화\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"세그멘테이션 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **분할 기반으로 결함 면적과 깊이를 측정하고 손상도를 자동 판정하는 모델을 만들어 기준 오차를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **조명 보정 후 임계값 분할(Mask R-CNN 대체)로 결함 마스크를 만들고 정답 마스크와의 IoU·Dice로 정밀도 평가하고 성능을 비교하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/features/qlt03_feature_engineering.py 및 기준 모델` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군수 실무에서 납득 가능한 피처 설계의 논리성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 파생 피처 10개 이상 생성, 기준 모델 MAPE 25% 이하 달성\n",
"\n",
"\n",
"- 진행 기준: 피처 파이프라인 및 베이스라인 모델 성능 지표 도출\n",
"\n",
"\n",
"- 검증 방법: 교차 검증(Time-series Cross Validation)을 통한 과적합 방지\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"분할(thresholding 기반 결함 후보 분할, Mask R-CNN 대체) 결과에서 결함 면적을 측정·보정하고 깊이와 손상도 점수를 학습해 손상도를 자동 판정하며, 분할 IoU·면적/깊이/손상도 오차(MAPE)와 변수군 제거 실험으로 파생 피처 설계의 타당성을 검증한다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n",
"from sklearn.linear_model import LinearRegression\n",
"from sklearn.model_selection import cross_val_predict\n",
"from sklearn.metrics import r2_score\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 4] 세그멘테이션(Mask R-CNN) 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 4] 세그멘테이션(Mask R-CNN) 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n",
"def fit_pipeline(meta, F):\n",
" \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n",
" clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n",
" P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n",
" clf.fit(F, meta[\"cls\"])\n",
" reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n",
" return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n",
"\n",
"\n",
"def predict_pipeline(pl, F):\n",
" X = F[pl[\"cols\"]]\n",
" P = pl[\"clf\"].predict_proba(X)\n",
" idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n",
" return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n",
"\n",
"mtr, itr, ktr = make_images(600, seed=1)\n",
"mte, ite, kte = make_images(300, seed=2)\n",
"Ftr, Fte = feature_table(itr), feature_table(ite)\n",
"print(f\"[1] 파생 피처 {Ftr.shape[1]}개(분할 면적·성분 수·모양, 명암, 색, 가장자리, 위치) / 학습 {len(mtr)}장, 평가 {len(mte)}장 (1화소 = 1.5mm 가정)\")\n",
"\n",
"# ---------------- 1) 분할 정밀도: 정답 마스크와의 IoU ----------------\n",
"seg = [segment(im)[0] for im in ite]\n",
"rows = []\n",
"for c in CLASSES[1:]:\n",
" ix = np.where(mte[\"cls\"].values == c)[0]\n",
" iou = [(seg[i] & kte[i]).sum() / max((seg[i] | kte[i]).sum(), 1) for i in ix]\n",
" dice = [2 * (seg[i] & kte[i]).sum() / max(seg[i].sum() + kte[i].sum(), 1) for i in ix]\n",
" hit = [seg[i].sum() > 0 and (seg[i] & kte[i]).sum() > 0 for i in ix]\n",
" rows.append((KO_CLS[c], len(ix), np.mean(iou), np.mean(dice), np.mean(hit)))\n",
"sg = pd.DataFrame(rows, columns=[\"결함\", \"장수\", \"평균 IoU\", \"평균 Dice\", \"검출률\"])\n",
"print(\"[2] 분할 성능(평가 300장)\\n\" + sg.round(2).to_string(index=False))\n",
"ok0 = np.mean([seg[i].sum() == 0 or (seg[i].sum() < 12) for i in np.where(mte[\"cls\"].values == \"none\")[0]])\n",
"print(f\" 결함 없는 사진 {int((mte['cls'] == 'none').sum())}장 중 분할 결과가 거의 비어 있는(12화소 미만) 사진 {ok0:.0%} (무해한 얼룩·스크래치 오탐 억제)\")\n",
"\n",
"# ---------------- 2) 면적 측정과 유형별 보정 ----------------\n",
"pl = fit_pipeline(mtr, Ftr)\n",
"cls_te, P_te, idx_te, conf_te = predict_pipeline(pl, Fte)\n",
"a_true, a_raw = mte[\"area_frac\"].values, Fte[\"area_frac\"].values\n",
"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:]}\n",
"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)])\n",
"m = a_true > 0\n",
"mp = lambda y, p: float(np.mean(np.abs(y - p) / y) * 100)\n",
"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%})\")\n",
"print(f\" 면적 환산 예: 평균 결함 면적 {a_true[m].mean() * SZ ** 2 * 1.5 ** 2:.0f}mm2(정답) / {a_cal[m].mean() * SZ ** 2 * 1.5 ** 2:.0f}mm2(보정 측정)\")\n",
"\n",
"# ---------------- 3) 깊이·손상도 모델 ----------------\n",
"dm_tr = mtr[\"depth_mm\"].values > 0.2\n",
"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\")]\n",
"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])\n",
"d_pred = np.clip(dep.predict(np.c_[Fte[pl[\"cols\"]].values, P_te]), 0, None)\n",
"dm = mte[\"depth_mm\"].values > 0.2\n",
"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\")\n",
"print(\" 깊이는 사진 한 장으로 직접 관측되지 않아 면적·명암에서 간접 추정하므로 손상도보다 오차가 크다(실제 업무는 깊이 게이지 등 현장 측정으로 확인).\")\n",
"y = mte[\"damage_idx\"].values\n",
"big = y >= 10\n",
"mape_d, mae_d = mp(y[big], idx_te[big]), float(np.mean(np.abs(y - idx_te)))\n",
"base = LinearRegression().fit(Ftr[[\"area_px\"]], mtr[\"damage_idx\"])\n",
"b_pred = np.clip(base.predict(Fte[[\"area_px\"]]), 0, 100)\n",
"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}\")\n",
"g_true, g_pred = mte[\"grade\"].values, grade_of(idx_te)\n",
"print(f\" 정비 등급 일치율 {(g_true == g_pred).mean():.1%}, 한 단계 이내 {(np.abs(g_true - g_pred) <= 1).mean():.1%}\")\n",
"\n",
"# ---------------- 4) 변수군 제거 실험 ----------------\n",
"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\"],\n",
" \"색(붉은 갈색)\": [\"rust_frac\", \"chroma_mean\", \"chroma_max\", \"in_chroma\", \"sat\"], \"가장자리·테두리\": [\"edge_mean\", \"edge_p95\", \"rim_frac\"], \"조명\": [\"gain\"]}\n",
"full_cols = list(Ftr.columns)\n",
"\n",
"\n",
"def run(cols):\n",
" g = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(Ftr[cols], mtr[\"damage_idx\"])\n",
" p = np.clip(g.predict(Fte[cols]), 0, 100)\n",
" return mp(y[big], p[big])\n",
"\n",
"\n",
"full = run(full_cols)\n",
"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)\n",
"print(f\"\\n[6] 변수군 제거(분류 확률 없이 24개 피처만 사용, 전체 MAPE {full:.1f}%)\\n\" + ab.round(2).to_string(index=False))\n",
"print(f\" 해석: 제거 시 오차가 가장 크게 늘어난 변수군은 '{ab.iloc[0]['제거한 변수군']}'(+{ab.iloc[0]['증가']:.1f}%p)이다. 변수군끼리 정보가 겹쳐 단독 제거 효과는 작게 나타날 수 있다.\")\n",
"print(\" 판정은 정비 의사결정 보조이며 최종 등급은 검사관이 확정한다(M7). 사진·장비는 모두 교육용 가상 자료이다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n",
"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\")\n",
"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\")\n",
"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\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1] 파생 피처 24개(분할 면적·성분 수·모양, 명암, 색, 가장자리, 위치) / 학습 600장, 평가 300장 (1화소 = 1.5mm 가정)\n",
"[2] 분할 성능(평가 300장)\n",
"결함 장수 평균 IoU 평균 Dice 검출률\n",
"균열 48 0.89 0.94 1.0\n",
"부식 77 0.74 0.84 1.0\n",
"파손 57 0.61 0.71 1.0\n",
"누유 48 0.94 0.97 1.0\n",
" 결함 없는 사진 70장 중 분할 결과가 거의 비어 있는(12화소 미만) 사진 100% (무해한 얼룩·스크래치 오탐 억제)\n",
"\n",
"[3] 면적 측정(결함 230장): 분할 원측정 MAPE 20.8% -> 유형별 선형 보정 후 10.2% (분류 정확도 98.7%)\n",
" 면적 환산 예: 평균 결함 면적 565mm2(정답) / 548mm2(보정 측정)\n",
"[4] 깊이 추정(깊이 있는 유형 181장, 누유는 깊이 0으로 제외): MAPE 28.0%, MAE 0.40mm\n",
" 깊이는 사진 한 장으로 직접 관측되지 않아 면적·명암에서 간접 추정하므로 손상도보다 오차가 크다(실제 업무는 깊이 게이지 등 현장 측정으로 확인).\n",
"[5] 손상도 점수(0~100, 점수 10 이상 220장): 면적만 쓰는 기준 MAPE 30.8% -> 제안 모델 MAPE 8.8% (목표 25% 이하: 달성), MAE 3.22, R2 0.946\n",
" 정비 등급 일치율 93.3%, 한 단계 이내 99.7%\n",
"\n",
"[6] 변수군 제거(분류 확률 없이 24개 피처만 사용, 전체 MAPE 10.5%)\n",
" 제거한 변수군 MAPE(%) 증가\n",
" 색(붉은 갈색) 13.46 2.98\n",
" 분할 면적·모양 11.45 0.97\n",
"명암(어두운 비율·대비) 11.29 0.81\n",
" 조명 10.67 0.19\n",
" 가장자리·테두리 10.60 0.12\n",
" 해석: 제거 시 오차가 가장 크게 늘어난 변수군은 '색(붉은 갈색)'(+3.0%p)이다. 변수군끼리 정보가 겹쳐 단독 제거 효과는 작게 나타날 수 있다.\n",
" 판정은 정비 의사결정 보조이며 최종 등급은 검사관이 확정한다(M7). 사진·장비는 모두 교육용 가상 자료이다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 05. 정비 등급(현장정비/야전정비/창정비) 자동 분류 및 판정 신뢰도 지수 산출 엔진 개발\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"정비 등급(현장/야전/창정비) 자동 분류 및 신뢰도 지수 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **손상도와 결함 유형 확률로 정비 등급(현장정비·야전정비·창정비)을 자동 분류하고 판정 신뢰도 지수를 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **분할 특징과 유형 확률로 손상도를 회귀하고 경계값으로 정비 등급을 정해 기준 모델(밝기 히스토그램 회귀)과 MAE·RMSE·R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/models/qlt03_advanced_model.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 비정형/불규칙 수요에 대한 모델의 적응력 및 예측 안정성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 기준 모델 대비 오차율(MAE/RMSE) 20% 이상 개선, R2 Score 0.85 이상\n",
"\n",
"\n",
"- 진행 기준: 모델 하이퍼파라미터 튜닝 완료 및 최적 모델 가중치 저장\n",
"\n",
"\n",
"- 검증 방법: Hold-out 테스트 데이터셋 대상 최종 성능 평가\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"손상도 점수 회귀와 결함 유형 확률로 정비 등급(현장정비·야전정비·창정비)을 자동 분류하고, 분류 확률·등급 경계까지의 거리·사진 품질로 판정 신뢰도 지수(0~100)를 산출하는 엔진을 만들어 기준 모델 대비 MAE·RMSE 개선과 신뢰도 보정, 저조도·진흙 사진 등 불규칙 상황에서의 안정성을 검증한다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n",
"from sklearn.linear_model import LinearRegression\n",
"from sklearn.model_selection import cross_val_predict\n",
"from sklearn.metrics import r2_score, confusion_matrix\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 5] 정비 등급(현장정비/야전정비/창정비) 자동 분류 및 판정 신뢰도 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 5] 정비 등급(현장정비/야전정비/창정비) 자동 분류 및 판정 신뢰도 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n",
"def fit_pipeline(meta, F):\n",
" \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n",
" clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n",
" P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n",
" clf.fit(F, meta[\"cls\"])\n",
" reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n",
" return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n",
"\n",
"\n",
"def predict_pipeline(pl, F):\n",
" X = F[pl[\"cols\"]]\n",
" P = pl[\"clf\"].predict_proba(X)\n",
" idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n",
" return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n",
"# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n",
"def mud_est(img):\n",
" \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n",
" sm = ndi.uniform_filter(img, size=(3, 3, 1))\n",
" s = sm.sum(axis=2) + 1e-6\n",
" rr, gg = sm[..., 0] / s, sm[..., 1] / s\n",
" return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n",
"\n",
"\n",
"def confidence(P, idx, F, mud):\n",
" \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n",
" bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n",
" 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)\n",
" c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n",
" return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n",
"\n",
"mtr, itr, _ = make_images(600, seed=1)\n",
"mte, ite, _ = make_images(300, seed=2)\n",
"Ftr, Fte = feature_table(itr), feature_table(ite)\n",
"pl = fit_pipeline(mtr, Ftr)\n",
"\n",
"\n",
"def hist_feat(imgs):\n",
" \"\"\"기준 모델(M3) 특징: 밝기 히스토그램 16칸 + 채널 평균 비율 3 + 변동계수 1.\"\"\"\n",
" out = []\n",
" for im in imgs:\n",
" g = im.mean(axis=2)\n",
" h, _ = np.histogram(g / max(g.max(), 1e-3), bins=16, range=(0, 1))\n",
" out.append(np.r_[h / g.size, im.mean(axis=(0, 1)) / max(g.mean(), 1e-3), g.std() / max(g.mean(), 1e-3)])\n",
" return np.array(out)\n",
"\n",
"\n",
"base = LinearRegression().fit(hist_feat(itr), mtr[\"damage_idx\"])\n",
"rm = lambda y, p: float(np.sqrt(np.mean((np.asarray(y) - np.asarray(p)) ** 2)))\n",
"\n",
"\n",
"# ---------------- 1) 평가 300장: 기준 모델 vs 제안 엔진 ----------------\n",
"cls, P, idx, _ = predict_pipeline(pl, Fte)\n",
"y = mte[\"damage_idx\"].values\n",
"bp = np.clip(base.predict(hist_feat(ite)), 0, 100)\n",
"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\"])\n",
"print(\"[1] 손상도 점수 예측 성능(평가 300장)\\n\" + tab.round(3).to_string())\n",
"b, e = tab.iloc[:, 0], tab.iloc[:, 1]\n",
"ok = 1 - e[\"MAE\"] / b[\"MAE\"] >= .2 and 1 - e[\"RMSE\"] / b[\"RMSE\"] >= .2\n",
"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 '미달'})\")\n",
"\n",
"# ---------------- 2) 정비 등급 분류 ----------------\n",
"g_true, g_pred = mte[\"grade\"].values, grade_of(idx)\n",
"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)])\n",
"under = int(((g_pred < g_true)).sum())\n",
"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())\n",
"print(\" 창정비를 야전·현장으로 낮추는 과소 판정이 가장 위험하므로 경계 부근은 신뢰도로 걸러 검사관이 확인한다.\")\n",
"\n",
"# ---------------- 3) 판정 신뢰도 지수 보정 ----------------\n",
"mud_te = np.array([mud_est(im) for im in ite])\n",
"conf = confidence(P, idx, Fte, mud_te)\n",
"bins = pd.cut(conf, [0, 70, 80, 90, 101], labels=[\"70 미만\", \"70~80\", \"80~90\", \"90 이상\"])\n",
"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\"))\n",
"print(\"\\n[3] 신뢰도 지수 구간별 실제 일치율(높은 신뢰도일수록 높아야 한다)\\n\" + cal.round(3).to_string())\n",
"hi = conf >= 80\n",
"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%})\")\n",
"\n",
"# ---------------- 4) 불규칙 상황: 저조도, 진흙 가림, 작은 결함 ----------------\n",
"rows = []\n",
"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))]:\n",
" mm, ii = (mte, ite) if name == \"정상 조명\" else make_images(150, **kw)[:2]\n",
" FF = Fte if name == \"정상 조명\" else feature_table(ii)\n",
" c2, P2, i2, _ = predict_pipeline(pl, FF)\n",
" cf = confidence(P2, i2, FF, [mud_est(im) for im in ii])\n",
" yy = mm[\"damage_idx\"].values\n",
" gg, gp = mm[\"grade\"].values, grade_of(i2)\n",
" 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()))\n",
"ir = pd.DataFrame(rows, columns=[\"조건\", \"장수\", \"기준 MAE\", \"제안 MAE\", \"등급 일치율\", \"평균 신뢰도\", \"신뢰도 80 이상 일치율\", \"80 이상 비율\"])\n",
"print(\"\\n[4] 불규칙 상황별 성능(- 는 해당 사진 없음)\\n\" + ir.round(2).to_string(index=False, na_rep=\"-\"))\n",
"print(\" 진흙 가림이 커지면 오차와 과소 판정이 늘고 평균 신뢰도가 떨어진다. 이런 사진은 재촬영 요구 또는 검사관 확인으로 보낸다.\")\n",
"print(\" 등급은 정비 소요의 1차 판정이며, 최종 정비 등급은 검사관이 확정한다(M7). 사진은 교육용 가상 자료이다.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1] 손상도 점수 예측 성능(평가 300장)\n",
" 기준(밝기 히스토그램 회귀) 제안(분할 특징+유형 확률)\n",
"MAE 12.107 3.223\n",
"RMSE 15.045 6.759\n",
"R2 0.732 0.946\n",
" MAE 개선 73.4%, RMSE 개선 55.1% (목표 20% 이상: 달성), R2 0.946 (목표 0.85 이상: 달성)\n",
"\n",
"[2] 정비 등급 판정: 일치율 93.3%, 한 단계 이내 99.7%, 과소 판정(실제보다 낮게) 16장, 과대 판정 4장\n",
" 판정 현장정비 판정 야전정비 판정 창정비\n",
"실제 현장정비 182 2 0\n",
"실제 야전정비 11 47 2\n",
"실제 창정비 1 4 51\n",
" 창정비를 야전·현장으로 낮추는 과소 판정이 가장 위험하므로 경계 부근은 신뢰도로 걸러 검사관이 확인한다.\n",
"\n",
"[3] 신뢰도 지수 구간별 실제 일치율(높은 신뢰도일수록 높아야 한다)\n",
" 건수 일치율 한단계이내\n",
"bin \n",
"70 미만 35 0.543 1.000\n",
"70~80 31 0.871 0.968\n",
"80~90 80 1.000 1.000\n",
"90 이상 154 1.000 1.000\n",
" 신뢰도 80 이상 자동 통과 234장(78%): 일치율 100.0% / 80 미만 66장은 검사관 확인 대상(일치율 69.7%)\n",
"\n",
"[4] 불규칙 상황별 성능(- 는 해당 사진 없음)\n",
" 조건 장수 기준 MAE 제안 MAE 등급 일치율 평균 신뢰도 신뢰도 80 이상 일치율 80 이상 비율\n",
" 정상 조명 300 12.11 3.22 0.93 86.19 1.0 0.78\n",
" 저조도 150 13.17 5.52 0.93 74.60 1.0 0.45\n",
"진흙 20% 가림 150 53.72 23.89 0.45 61.91 - 0.00\n",
"진흙 35% 가림 150 66.17 19.41 0.57 59.80 - 0.00\n",
" 진흙 가림이 커지면 오차와 과소 판정이 늘고 평균 신뢰도가 떨어진다. 이런 사진은 재촬영 요구 또는 검사관 확인으로 보낸다.\n",
" 등급은 정비 소요의 1차 판정이며, 최종 정비 등급은 검사관이 확정한다(M7). 사진은 교육용 가상 자료이다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 06. 장비 결함 사진 업로드 및 손상 부위 시각화 판정 인터페이스 구현\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"장비 결함 사진 업로드 및 손상 부위 시각화 인터페이스를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 결함 사진을 업로드하면 손상 부위를 시각화해 판정과 근거를 보여 주는 인터페이스를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **업로드 사진 한 장에서 결함 유형, 면적(mm2), 깊이, 손상도, 정비 등급, 신뢰도와 손상 부위 윤곽·박스를 계산해 화면에 겹쳐 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/explainability/qlt03_xai_explainer.py 및 XAI 리포트` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군수 장교/군무원이 신뢰하고 조치할 수 있는 설명의 명확성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%\n",
"\n",
"\n",
"- 진행 기준: SHAP summary plot 및 waterfall plot 생성 파이프라인 완성\n",
"\n",
"\n",
"- 검증 방법: 실무자 대상 설명 근거 유용성 평가 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"장비 결함 사진을 업로드하면 결함 유형·면적·깊이·손상도·정비 등급·신뢰도와 손상 부위(박스·윤곽)를 시각화해 보여 주는 판정 인터페이스를 구현하고, 판정 건마다 근거 변수군 기여도 상위 3개를 자동 산출해 자연어 설명으로 제공한다(자동 확정 없이 검사 대기로 등록).\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n",
"from sklearn.model_selection import cross_val_predict\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 6] 장비 결함 사진 업로드 및 손상 부위 시각화 판정 인터페이스 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 6] 장비 결함 사진 업로드 및 손상 부위 시각화 판정 인터페이스 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n",
"def fit_pipeline(meta, F):\n",
" \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n",
" clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n",
" P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n",
" clf.fit(F, meta[\"cls\"])\n",
" reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n",
" return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n",
"\n",
"\n",
"def predict_pipeline(pl, F):\n",
" X = F[pl[\"cols\"]]\n",
" P = pl[\"clf\"].predict_proba(X)\n",
" idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n",
" return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n",
"# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n",
"def mud_est(img):\n",
" \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n",
" sm = ndi.uniform_filter(img, size=(3, 3, 1))\n",
" s = sm.sum(axis=2) + 1e-6\n",
" rr, gg = sm[..., 0] / s, sm[..., 1] / s\n",
" return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n",
"\n",
"\n",
"def confidence(P, idx, F, mud):\n",
" \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n",
" bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n",
" 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)\n",
" c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n",
" return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n",
"\n",
"mtr, itr, _ = make_images(600, seed=1)\n",
"Ftr = feature_table(itr)\n",
"pl = fit_pipeline(mtr, Ftr)\n",
"reg_p = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.07, random_state=0)\n",
"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\")]\n",
"dm = mtr[\"depth_mm\"].values > 0.2\n",
"depth_model = reg_p.fit(Xd[dm], mtr[\"depth_mm\"].values[dm])\n",
"MM2 = 1.5 ** 2 # 1화소 = 1.5mm 가정 -> 면적 mm2\n",
"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\"],\n",
" \"붉은 갈색 색상\": [\"rust_frac\", \"chroma_mean\", \"chroma_max\", \"in_chroma\", \"sat\"], \"가장자리·테두리\": [\"edge_mean\", \"edge_p95\", \"rim_frac\"], \"조명 밝기\": [\"gain\"]}\n",
"MEAN = Ftr.mean()\n",
"\n",
"\n",
"def contrib(F):\n",
" \"\"\"변수군을 평균값으로 대체했을 때 손상도 점수 변화(양수 = 그 변수군이 손상도를 높인 요인). 사진 여러 장을 한 번에 계산한다.\"\"\"\n",
" full = predict_pipeline(pl, F)[2]\n",
" out = {}\n",
" for g, cols in GROUPS.items():\n",
" x = F.copy(); x[cols] = MEAN[cols].values\n",
" out[g] = full - predict_pipeline(pl, x)[2]\n",
" return pd.DataFrame(out, index=F.index)\n",
"\n",
"\n",
"def top3(F):\n",
" return contrib(F).apply(lambda r: r.reindex(r.abs().sort_values(ascending=False).index)[:3].to_dict(), axis=1)\n",
"\n",
"\n",
"def inspect(img, pid):\n",
" \"\"\"업로드 사진 한 장을 판정한다. 결과는 항상 '검사 대기'이며 자동 확정하지 않는다.\"\"\"\n",
" F = feature_table([img])\n",
" cls, P, idx, _ = predict_pipeline(pl, F)\n",
" mud = mud_est(img)\n",
" conf = float(confidence(P, idx, F, [mud])[0])\n",
" m = segment(img)[0]\n",
" lab, n = ndi.label(m)\n",
" box = None\n",
" if n:\n",
" k = int(np.argmax(ndi.sum(m, lab, range(1, n + 1)))) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" box = (sl[1].start, sl[0].start, sl[1].stop - sl[1].start, sl[0].stop - sl[0].start)\n",
" 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\n",
" if mud >= 0.03 or F[\"gain\"].iloc[0] < 0.17:\n",
" state = \"재촬영 요구(오염·저조도)\"\n",
" elif conf >= 80:\n",
" state = \"검사 대기(신뢰도 높음, 검사관 확인 후 확정)\"\n",
" else:\n",
" state = \"검사 대기(신뢰도 낮음, 검사관 정밀 확인)\"\n",
" 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}\n",
"\n",
"\n",
"# ---------------- 1) 업로드 시뮬레이션: 조건이 다른 사진 8장 ----------------\n",
"mt, it, kt = make_images(300, seed=2)\n",
"mud_m, imud, kmud = make_images(30, seed=11, mud=0.2)\n",
"dark_m, idk, kdk = make_images(30, seed=12, dark=True)\n",
"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\"),\n",
" (\"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\"),\n",
" (\"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)]\n",
"res = []\n",
"for pid, img, _, _, _ in picks:\n",
" res.append(inspect(img, pid))\n",
"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]]\n",
"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\"],\n",
" \"등급\": GRADE_NAME[r[\"grade\"]], \"실제 등급\": GRADE_NAME[int(t[\"grade\"])], \"신뢰도\": r[\"conf\"], \"오염\": r[\"mud\"]} for r, t in zip(res, true_rows)])\n",
"print(\"[1] 업로드 판정 결과(8장, 면적은 1화소 1.5mm 가정) [교육용가상자료]\")\n",
"print(tb.round(2).to_string(index=False))\n",
"for r in res:\n",
" print(f\" {r['id']}: {r['state']}\")\n",
"\n",
"# ---------------- 2) 기여도 상위 3개 자동 산출(평가 150장) ----------------\n",
"mte, ite, _ = make_images(300, seed=2)\n",
"Fte = feature_table(ite)\n",
"T3 = top3(Fte)\n",
"ok = int(sum(len(t) == 3 and all(np.isfinite(v) for v in t.values()) for t in T3))\n",
"texts = list(T3)\n",
"print(f\"\\n[2] 평가 {len(ite)}장 중 기여도 상위 3개 요인 자동 산출 {ok}건 ({ok / len(ite):.0%}, 목표 100%)\")\n",
"avg = pd.DataFrame(texts).abs().mean().sort_values(ascending=False)\n",
"print(\" 변수군별 평균 기여 크기(손상도 점):\", \", \".join(f\"{k} {v:.1f}\" for k, v in avg.items()))\n",
"\n",
"\n",
"def explain(r):\n",
" t3 = top3(r[\"F\"]).iloc[0]\n",
" why = {\"결함 면적·모양\": (\"결함 면적·모양이 평균보다 커서\", \"결함 면적·모양이 평균보다 작아\"), \"명암 대비\": (\"표면과 크게 다른 어두운 영역이 있어\", \"어두운 영역이 적어\"),\n",
" \"붉은 갈색 색상\": (\"붉은 갈색 부식 색상이 넓어\", \"붉은 갈색 색상이 적어\"), \"가장자리·테두리\": (\"뚜렷한 테두리가 있어\", \"테두리 단서가 약해\"), \"조명 밝기\": (\"조명 밝기 영향으로\", \"조명 밝기 영향으로\")}\n",
" main = list(t3)[0]\n",
" 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())\n",
"\n",
"\n",
"print(\"[3] 판정 근거 자연어 설명(결함 있는 3장)\")\n",
"for r in (res[0], res[1], res[2]):\n",
" print(\" -\", explain(r))\n",
"\n",
"# ---------------- 3) 손상 부위 시각화 정확도: 박스와 정답 마스크 겹침 ----------------\n",
"ious = []\n",
"for r, (pid, img, kk, mm, _) in zip(res[:4], picks[:4]):\n",
" i = next(j for j in range(300) if mt.cls[j] == {\"U01\": \"crack\", \"U02\": \"corrosion\", \"U03\": \"damage\", \"U04\": \"leak\"}[pid])\n",
" gt = kk[i]\n",
" ious.append((pid, (r[\"mask\"] & gt).sum() / max((r[\"mask\"] | gt).sum(), 1)))\n",
"print(\"[4] 손상 부위 표시와 정답 마스크의 IoU:\", \", \".join(f\"{p} {v:.2f}\" for p, v in ious))\n",
"print(\" 화면은 자동 확정 버튼 없이 '검사 대기 등록'만 제공하며, 최종 정비 등급은 검사관이 확정한다(M7).\")\n",
"\n",
"fig, ax = plt.subplots(2, 4, figsize=(14, 7))\n",
"for a, (pid, img, _, _, _), r in zip(ax.ravel(), picks, res):\n",
" a.imshow(img)\n",
" a.contour(r[\"mask\"], levels=[0.5], colors=\"yellow\", linewidths=1)\n",
" if r[\"box\"]:\n",
" x, y, w, h = r[\"box\"]; a.add_patch(plt.Rectangle((x - .5, y - .5), w, h, fill=False, ec=\"red\", lw=1.5))\n",
" a.set_title(f\"{pid} {r['cls']} idx {r['idx']:.0f} grade {r['grade']} conf {r['conf']:.0f}\", fontsize=9); a.axis(\"off\")\n",
"plt.suptitle(\"Upload inspection view (synthetic photos): yellow = defect region, red = box\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1] 업로드 판정 결과(8장, 면적은 1화소 1.5mm 가정) [교육용가상자료]\n",
" 사진 판정 유형 실제 유형 면적(mm2) 깊이(mm) 손상도 실제 손상도 등급 실제 등급 신뢰도 오염\n",
"U01 균열 균열 121.50 1.67 19.39 25.53 현장정비 현장정비 91.72 0.00\n",
"U02 부식 부식 171.00 0.44 24.12 24.49 현장정비 현장정비 93.58 0.00\n",
"U03 파손 파손 857.25 4.91 82.32 85.72 창정비 창정비 96.30 0.00\n",
"U04 누유 누유 265.50 0.00 14.31 14.60 현장정비 현장정비 88.68 0.00\n",
"U05 결함 없음 결함 없음 0.00 0.00 1.17 0.00 현장정비 현장정비 91.61 0.00\n",
"U06 누유 누유 231.75 0.00 15.84 10.12 현장정비 현장정비 86.90 0.00\n",
"U07 파손 결함 없음 103.50 3.21 70.75 1.96 창정비 현장정비 64.69 0.02\n",
"U08 파손 파손 339.75 4.17 57.16 66.53 야전정비 창정비 72.90 0.02\n",
" U01: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n",
" U02: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n",
" U03: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n",
" U04: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n",
" U05: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n",
" U06: 재촬영 요구(오염·저조도)\n",
" U07: 검사 대기(신뢰도 낮음, 검사관 정밀 확인)\n",
" U08: 검사 대기(신뢰도 낮음, 검사관 정밀 확인)\n",
"\n",
"[2] 평가 300장 중 기여도 상위 3개 요인 자동 산출 300건 (100%, 목표 100%)\n",
" 변수군별 평균 기여 크기(손상도 점): 결함 면적·모양 25.1, 붉은 갈색 색상 6.3, 명암 대비 5.3, 조명 밝기 2.6, 가장자리·테두리 1.0\n",
"[3] 판정 근거 자연어 설명(결함 있는 3장)\n",
" - U01: 결함 면적·모양이 평균보다 작아 손상도 19점(현장정비) 판정. 주요 요인 결함 면적·모양(-18.2점), 명암 대비(-5.0점), 붉은 갈색 색상(-1.0점)\n",
" - U02: 결함 면적·모양이 평균보다 작아 손상도 24점(현장정비) 판정. 주요 요인 결함 면적·모양(-11.6점), 명암 대비(-1.8점), 붉은 갈색 색상(+1.7점)\n",
" - U03: 결함 면적·모양이 평균보다 커서 손상도 82점(창정비) 판정. 주요 요인 결함 면적·모양(+23.9점), 붉은 갈색 색상(+19.6점), 명암 대비(+4.7점)\n",
"[4] 손상 부위 표시와 정답 마스크의 IoU: U01 0.95, U02 0.64, U03 0.80, U04 0.98\n",
" 화면은 자동 확정 버튼 없이 '검사 대기 등록'만 제공하며, 최종 정비 등급은 검사관이 확정한다(M7).\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 07. 외관 판정 결과에 대한 검사관 수동 보정 및 최종 정비 등급 HITL 승인 절차 설계\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"외관 판정 결과에 대한 검사관 수동 보정 및 HITL 승인 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AI 외관 판정에 대한 검사관 수동 보정과 최종 정비 등급 승인 HITL 절차를 설계하고 검사 운영 비용을 최적화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **신뢰도 기준으로 간이 승인과 정밀 판독을 나누는 운영안을 전수 정밀 판독 관행과 비교해 비용(판독·과소·과대 판정 손실)을 계산하고, 신뢰도 기준과 검사관 일일 한도에 따른 민감도를 본다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/optimization/qlt03_simulator.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n",
"\n",
"\n",
"- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n",
"\n",
"\n",
"- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"AI 정비 등급 판정을 신뢰도에 따라 간이 승인·정밀 판독으로 나누는 검사 운영안을 비용으로 최적화하고(전수 정밀 판독 관행 대비, 검사관 일일 한도 제약 반영), 모든 판정을 검사관 수동 보정 후 정비감독관(창정비·보정 건은 품질보증관까지)이 직접 승인하는 HITL 절차(권한·사유·해시 체인 감사 로그)를 구현한다.\"\"\"\n",
"import hashlib\n",
"import json\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n",
"from sklearn.model_selection import cross_val_predict\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 7] 외관 판정 결과에 대한 검사관 수동 보정 및 최종 정비 등급 HITL 승인 절차 설계 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 7] 외관 판정 결과에 대한 검사관 수동 보정 및 최종 정비 등급 HITL 승인 절차 설계 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n",
"def fit_pipeline(meta, F):\n",
" \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n",
" clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n",
" P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n",
" clf.fit(F, meta[\"cls\"])\n",
" reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n",
" return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n",
"\n",
"\n",
"def predict_pipeline(pl, F):\n",
" X = F[pl[\"cols\"]]\n",
" P = pl[\"clf\"].predict_proba(X)\n",
" idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n",
" return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n",
"# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n",
"def mud_est(img):\n",
" \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n",
" sm = ndi.uniform_filter(img, size=(3, 3, 1))\n",
" s = sm.sum(axis=2) + 1e-6\n",
" rr, gg = sm[..., 0] / s, sm[..., 1] / s\n",
" return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n",
"\n",
"\n",
"def confidence(P, idx, F, mud):\n",
" \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n",
" bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n",
" 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)\n",
" c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n",
" return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n",
"\n",
"mtr, itr, _ = make_images(600, seed=1)\n",
"Ftr = feature_table(itr)\n",
"pl = fit_pipeline(mtr, Ftr)\n",
"\n",
"\n",
"def judge(seed, n, **kw):\n",
" \"\"\"사진 n장을 만들어 AI 판정(등급·신뢰도)과 정답 등급을 돌려준다.\"\"\"\n",
" m, im, _ = make_images(n, seed=seed, **kw)\n",
" F = feature_table(im)\n",
" cls, P, idx, _ = predict_pipeline(pl, F)\n",
" conf = confidence(P, idx, F, [mud_est(x) for x in im])\n",
" return pd.DataFrame({\"true\": m[\"grade\"].values, \"ai\": grade_of(idx), \"conf\": conf, \"idx\": idx, \"cls\": cls, \"id\": m[\"img_id\"].values})\n",
"\n",
"\n",
"SETS = {\"평상(정상 조명)\": judge(2, 300), \"저조도 사진 많음\": judge(7, 150, dark=True), \"진흙 가림 20%\": judge(8, 150, mud=0.2)}\n",
"\n",
"# ---------------- 1) 비용 모델(교육용 가정, 만원/장) ----------------\n",
"C_FULL, C_QUICK, C_AI = 0.9, 0.25, 0.05 # 정밀 판독, 간이 승인(AI 판정 열람 후 승인), AI 판정 전산 비용\n",
"P_HUMAN, P_CATCH = 0.08, 0.5 # 검사관 정밀 판독 오류율(+-1 등급), 간이 승인 시 AI 오판을 걸러내는 확률\n",
"LOSS_UNDER, LOSS_OVER = 20.0, 6.0 # 과소 판정(재입고·고장 위험) / 과대 판정(과다 정비) 1등급당 손실\n",
"CAP = 120 # 검사관 일일 정밀 판독 한도(장)\n",
"\n",
"\n",
"def loss(true, grade):\n",
" d = grade - true\n",
" return np.where(d < 0, -d * LOSS_UNDER, d * LOSS_OVER)\n",
"\n",
"\n",
"def operate(df, T, cap=CAP, seed=0):\n",
" \"\"\"신뢰도 T 이상은 간이 승인, 미만은 정밀 판독(검사관 보정). 정밀 판독 한도를 넘으면 신뢰도가 낮은 순서대로만 정밀 판독한다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" n = len(df)\n",
" full = (df[\"conf\"] < T).values\n",
" if full.sum() > cap:\n",
" order = np.argsort(df[\"conf\"].values)\n",
" full = np.zeros(n, bool); full[order[:cap]] = True\n",
" final = df[\"ai\"].values.copy()\n",
" err = rng.random(n) < P_HUMAN\n",
" human = np.clip(df[\"true\"].values + np.where(err, rng.choice([-1, 1], n), 0), 1, 3)\n",
" final[full] = human[full]\n",
" wrong_ai = (df[\"ai\"].values != df[\"true\"].values) & ~full\n",
" caught = wrong_ai & (rng.random(n) < P_CATCH)\n",
" final[caught] = df[\"true\"].values[caught]\n",
" cost = full.sum() * C_FULL + (~full).sum() * C_QUICK + n * C_AI + loss(df[\"true\"].values, final).sum()\n",
" return cost, full.mean(), (final == df[\"true\"].values).mean(), int(((final < df[\"true\"].values)).sum())\n",
"\n",
"\n",
"def practice(df, seed=0):\n",
" \"\"\"관행: 모든 사진을 검사관이 정밀 판독(한도 없음, 검사관 오류율 P_HUMAN).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" err = rng.random(len(df)) < P_HUMAN\n",
" final = np.clip(df[\"true\"].values + np.where(err, rng.choice([-1, 1], len(df)), 0), 1, 3)\n",
" return len(df) * C_FULL + loss(df[\"true\"].values, final).sum(), (final == df[\"true\"].values).mean()\n",
"\n",
"\n",
"rows = []\n",
"for name, df in SETS.items():\n",
" cp, ap = practice(df)\n",
" co, fr, ao, un = operate(df, 80)\n",
" ai_only = len(df) * C_AI + loss(df[\"true\"].values, df[\"ai\"].values).sum()\n",
" rows.append((name, len(df), cp, ai_only, co, 1 - co / cp, fr, ap, ao, un))\n",
"res = pd.DataFrame(rows, columns=[\"시나리오\", \"장수\", \"관행 전수 판독 비용\", \"AI 단독 비용(금지안)\", \"HITL 비용(T=80)\", \"절감률\", \"정밀 판독 비율\", \"관행 일치율\", \"HITL 일치율\", \"HITL 과소 판정\"])\n",
"print(\"[1] 시나리오별 검사 운영 비용(가정 단가, 만원)\")\n",
"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))\n",
"r0 = res.iloc[0]\n",
"passed = [f\"{r['시나리오']} {r['절감률']:.1%}\" for _, r in res.iterrows() if r[\"절감률\"] >= .15]\n",
"print(f\" 평상 절감 {r0['절감률']:.1%} (목표 15% 이상: {'달성' if r0['절감률'] >= .15 else '미달'}), 15% 이상 달성 시나리오: {', '.join(passed)}\")\n",
"print(f\" AI 단독 비용은 평상 {r0['AI 단독 비용(금지안)']:.0f}, 진흙 {res.iloc[2]['AI 단독 비용(금지안)']:.0f}로 오염 사진에서 과소 판정 손실이 폭증하므로 허용하지 않는다(모든 판정은 사람이 승인). 진흙 20%는 정밀 판독이 늘어 절감이 나지 않는다(미달).\")\n",
"\n",
"# ---------------- 2) 신뢰도 기준 T 탐색과 검사관 한도 ----------------\n",
"sw = []\n",
"for T in (60, 70, 80, 90, 95):\n",
" c, fr, a, un = operate(SETS[\"평상(정상 조명)\"], T)\n",
" sw.append((T, c, fr, a, un))\n",
"sw = pd.DataFrame(sw, columns=[\"기준 T\", \"비용\", \"정밀 판독 비율\", \"일치율\", \"과소 판정\"])\n",
"print(\"\\n[2] 신뢰도 기준 T 민감도(평상 300장)\\n\" + sw.round(3).to_string(index=False))\n",
"best = sw.loc[sw[\"비용\"].idxmin()]\n",
"print(f\" 비용 최소 기준 T={int(best['기준 T'])}. 기준을 너무 높이면 정밀 판독이 늘어 비용이 오르고, 너무 낮으면 AI 오판이 통과한다.\")\n",
"cp, _ = practice(SETS[\"진흙 가림 20%\"])\n",
"for cap in (150, 60, 20):\n",
" c, fr, a, un = operate(SETS[\"진흙 가림 20%\"], 80, cap=cap)\n",
" print(f\" 진흙 20% 시나리오, 검사관 한도 {cap}장/일: 비용 {c:.0f} (관행 대비 절감 {1 - c / cp:+.1%}), 정밀 판독 {fr:.0%}, 일치율 {a:.1%}, 과소 판정 {un}장\")\n",
"print(\" 검사관 한도가 줄면 신뢰도 낮은 사진까지 간이 승인으로 넘어가 AI 오판이 걸러지지 않아 손실이 커진다(비용 +). 오염 사진은 재촬영·사전 세척 안내로 줄여야 한다.\")\n",
"\n",
"# ---------------- 3) HITL 결재 워크플로우 ----------------\n",
"USERS = {\"ai_bot\": \"AI\", \"ins_kim\": \"INS\", \"ins_lee\": \"INS\", \"sup_park\": \"SUP\", \"qa_choi\": \"QA\", \"aud_han\": \"AUD\"}\n",
"PERM = {\"AI\": {\"submit\"}, \"INS\": {\"review\", \"correct\", \"reject\"}, \"SUP\": {\"approve\", \"reject\"}, \"QA\": {\"approve\", \"reject\"}, \"AUD\": {\"read\"}}\n",
"\n",
"\n",
"class Flow:\n",
" def __init__(self):\n",
" self.docs, self.log, self.blocked = {}, [], []\n",
"\n",
" def _audit(self, user, action, doc, detail):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" rec = {\"seq\": len(self.log) + 1, \"user\": user, \"action\": action, \"doc\": doc, \"detail\": detail, \"prev\": prev}\n",
" rec[\"hash\"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]\n",
" self.log.append(rec)\n",
"\n",
" def verify(self):\n",
" prev = \"GENESIS\"\n",
" for r in self.log:\n",
" body = {k: v for k, v in r.items() if k != \"hash\"}\n",
" if r[\"prev\"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12] != r[\"hash\"]:\n",
" return False, r[\"seq\"]\n",
" prev = r[\"hash\"]\n",
" return True, None\n",
"\n",
" def deny(self, user, act, doc, why):\n",
" self.blocked.append((user, act, doc, why)); self._audit(user, \"DENIED:\" + act, doc, why); return False\n",
"\n",
" def submit(self, user, pid, grade, conf, parent=None):\n",
" if \"submit\" not in PERM.get(USERS.get(user), set()):\n",
" return self.deny(user, \"submit\", \"-\", \"AI 판정 등록 권한 없음\")\n",
" did = f\"INS{len(self.docs) + 1:02d}\"\n",
" self.docs[did] = {\"photo\": pid, \"ai\": grade, \"grade\": grade, \"conf\": conf, \"status\": \"검사대기\", \"line\": [\"INS\", \"SUP\"], \"step\": 0, \"done\": [], \"parent\": parent}\n",
" self._audit(user, \"submit\", did, f\"{pid} AI 등급 {GRADE_NAME[grade]} 신뢰도 {conf:.0f}\")\n",
" return did\n",
"\n",
" def decide(self, user, did, act, reason=\"\", new_grade=None):\n",
" role, d = USERS.get(user), self.docs.get(did)\n",
" if role is None: return self.deny(user, act, did, \"미등록 사용자\")\n",
" if d is None or d[\"status\"] in (\"확정\", \"반려\"): return self.deny(user, act, did, \"결재 대상 아님(처리 완료)\")\n",
" if act not in PERM[role]: return self.deny(user, act, did, f\"{role} 역할은 {act} 불가\")\n",
" if d[\"line\"][d[\"step\"]] != role: return self.deny(user, act, did, \"결재 순서 위반\")\n",
" if user in d[\"done\"]: return self.deny(user, act, did, \"기결재자 재결재 금지\")\n",
" if len(reason.strip()) < 10: return self.deny(user, act, did, \"사유 10자 이상 필수\")\n",
" if act == \"reject\":\n",
" d[\"status\"] = \"반려\"\n",
" else:\n",
" if act == \"correct\":\n",
" d[\"grade\"] = new_grade\n",
" if new_grade != d[\"ai\"] and \"QA\" not in d[\"line\"]:\n",
" d[\"line\"] = d[\"line\"] + [\"QA\"] # 검사관이 AI 판정을 고치면 품질보증관까지 결재\n",
" if d[\"grade\"] == 3 and \"QA\" not in d[\"line\"]:\n",
" d[\"line\"] = d[\"line\"] + [\"QA\"] # 창정비 판정은 품질보증관까지 결재\n",
" d[\"done\"].append(user); d[\"step\"] += 1\n",
" d[\"status\"] = \"확정\" if d[\"step\"] == len(d[\"line\"]) else (\"감독대기\" if d[\"step\"] >= 1 else \"검사대기\")\n",
" self._audit(user, act, did, reason + (f\" -> {GRADE_NAME[new_grade]}\" if new_grade else \"\")); return True\n",
"\n",
" def edit(self, user, did):\n",
" if self.docs[did][\"status\"] == \"확정\": return self.deny(user, \"edit\", did, \"확정 후 등급 변경 금지(재검사 등록)\")\n",
" return True\n",
"\n",
"\n",
"base = SETS[\"평상(정상 조명)\"]\n",
"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)\n",
"wf = Flow()\n",
"ids = [wf.submit(\"ai_bot\", r.id, int(r.ai), r.conf) for r in mix.itertuples()]\n",
"print(f\"\\n[3] AI 판정 {len(ids)}건 전부 '검사대기' 등록, 자동 확정 {sum(wf.docs[i]['status'] == '확정' for i in ids)}건 (목표 0건)\")\n",
"low = [i for i, r in zip(ids, mix.itertuples()) if r.conf < 80][0]\n",
"first = ids[0]\n",
"wf.decide(\"ins_kim\", first, \"review\", \"AI 판정 열람, 사진과 일치 확인함\")\n",
"wf.decide(\"sup_park\", first, \"approve\", \"검사관 의견 확인, 등급 확정\")\n",
"wf.decide(\"ins_lee\", low, \"correct\", \"경계 부근 결함, 박스 재측정 후 등급 조정\", new_grade=min(3, wf.docs[low][\"ai\"] + 1))\n",
"wf.decide(\"sup_park\", low, \"approve\", \"보정 사유 타당, 품질보증 상신\")\n",
"wf.decide(\"qa_choi\", low, \"approve\", \"보정 근거 사진 대조 완료\")\n",
"rj = ids[2]\n",
"wf.decide(\"ins_kim\", rj, \"reject\", \"오염 사진으로 판독 불가, 재촬영 요구\")\n",
"new = wf.submit(\"ai_bot\", mix.id[2], int(mix.ai[2]), float(mix.conf[2]), parent=rj)\n",
"tgt = ids[-1]\n",
"attacks = [(\"ai_bot\", tgt, \"review\", \"AI 계정이 직접 검토 처리함\"), (\"sup_park\", tgt, \"approve\", \"검사관 검토 없이 감독관이 승인함\"), (\"aud_han\", tgt, \"approve\", \"감사 계정으로 승인 시도함\"),\n",
" (\"ghost\", tgt, \"review\", \"외부 계정으로 검토 시도함\"), (\"ins_kim\", tgt, \"review\", \"\"), (\"ins_kim\", tgt, \"correct\", \"짧음\"), (\"qa_choi\", tgt, \"approve\", \"품질보증관이 순서를 건너뛰어 승인\"),\n",
" (\"ins_kim\", first, \"review\", \"이미 확정된 건을 다시 검토함\"), (\"sup_park\", low, \"reject\", \"확정된 건을 뒤집어 반려함\")]\n",
"for u, doc, a, rs in attacks:\n",
" wf.decide(u, doc, a, rs, new_grade=2 if a == \"correct\" else None)\n",
"wf.edit(\"ins_kim\", first)\n",
"print(f\" 권한 우회 시도 10건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / 10:.0%}, 목표 100%)\")\n",
"for u, a, doc, why in wf.blocked[:4]:\n",
" print(f\" 차단 {u:<9} {a:<8} {doc}: {why}\")\n",
"ok, _ = wf.verify()\n",
"snap = json.loads(json.dumps(wf.log))\n",
"wf.log[3][\"detail\"] = \"사후에 고쳐 쓴 사유\"\n",
"ok2, at = wf.verify()\n",
"wf.log = snap\n",
"print(f\" 감사 로그 {len(wf.log)}건 해시 체인 무결성 {'100%' if ok else '훼손'}; 4번 기록 사후 수정 시 {'통과' if ok2 else f'위변조 검출({at}번)'}\")\n",
"print(f\" {low}: AI {GRADE_NAME[wf.docs[low]['ai']]} -> 검사관 보정 {GRADE_NAME[wf.docs[low]['grade']]}(보정 시 품질보증관까지 3단계 결재, 상태 {wf.docs[low]['status']}); {rj}(반려) -> {new}(재등록, parent 연결)\")\n",
"print(\" 문서 상태:\", pd.Series({k: v[\"status\"] for k, v in wf.docs.items()}).value_counts().to_dict(), \"| 최종 정비 등급은 검사관·정비감독관의 결재로 확정되며 AI는 확정 권한이 없다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n",
"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)\")\n",
"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)\")\n",
"ax[1].plot(sw[\"기준 T\"], sw[\"비용\"], \"o-\"); ax[1].set_title(\"Cost vs confidence threshold T\"); ax[1].set_xlabel(\"T\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1] 시나리오별 검사 운영 비용(가정 단가, 만원)\n",
" 시나리오 장수 관행 전수 판독 비용 AI 단독 비용(금지안) HITL 비용(T=80) 절감률 정밀 판독 비율 관행 일치율 HITL 일치율 HITL 과소 판정\n",
"평상(정상 조명) 300 442.0 379.0 178.9 59.5% 22% 94.3% 99.0% 2\n",
"저조도 사진 많음 150 263.0 219.5 189.0 28.2% 55% 92.0% 94.7% 3\n",
"진흙 가림 20% 150 203.0 771.5 219.0 -7.9% 80% 94.0% 89.3% 0\n",
" 평상 절감 59.5% (목표 15% 이상: 달성), 15% 이상 달성 시나리오: 평상(정상 조명) 59.5%, 저조도 사진 많음 28.2%\n",
" AI 단독 비용은 평상 379, 진흙 772로 오염 사진에서 과소 판정 손실이 폭증하므로 허용하지 않는다(모든 판정은 사람이 승인). 진흙 20%는 정밀 판독이 늘어 절감이 나지 않는다(미달).\n",
"\n",
"[2] 신뢰도 기준 T 민감도(평상 300장)\n",
" 기준 T 비용 정밀 판독 비율 일치율 과소 판정\n",
" 60 267.20 0.027 0.967 8\n",
" 70 158.75 0.117 0.990 2\n",
" 80 178.90 0.220 0.990 2\n",
" 90 238.00 0.400 0.977 2\n",
" 95 238.00 0.400 0.977 2\n",
" 비용 최소 기준 T=70. 기준을 너무 높이면 정밀 판독이 늘어 비용이 오르고, 너무 낮으면 AI 오판이 통과한다.\n",
" 진흙 20% 시나리오, 검사관 한도 150장/일: 비용 210 (관행 대비 절감 -3.7%), 정밀 판독 100%, 일치율 94.0%, 과소 판정 1장\n",
" 진흙 20% 시나리오, 검사관 한도 60장/일: 비용 300 (관행 대비 절감 -47.8%), 정밀 판독 40%, 일치율 81.3%, 과소 판정 3장\n",
" 진흙 20% 시나리오, 검사관 한도 20장/일: 비용 396 (관행 대비 절감 -95.1%), 정밀 판독 13%, 일치율 75.3%, 과소 판정 7장\n",
" 검사관 한도가 줄면 신뢰도 낮은 사진까지 간이 승인으로 넘어가 AI 오판이 걸러지지 않아 손실이 커진다(비용 +). 오염 사진은 재촬영·사전 세척 안내로 줄여야 한다.\n",
"\n",
"[3] AI 판정 8건 전부 '검사대기' 등록, 자동 확정 0건 (목표 0건)\n",
" 권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)\n",
" 차단 ai_bot review INS08: AI 역할은 review 불가\n",
" 차단 sup_park approve INS08: 결재 순서 위반\n",
" 차단 aud_han approve INS08: AUD 역할은 approve 불가\n",
" 차단 ghost review INS08: 미등록 사용자\n",
" 감사 로그 25건 해시 체인 무결성 100%; 4번 기록 사후 수정 시 위변조 검출(4번)\n",
" INS04: AI 야전정비 -> 검사관 보정 창정비(보정 시 품질보증관까지 3단계 결재, 상태 확정); INS03(반려) -> INS09(재등록, parent 연결)\n",
" 문서 상태: {'검사대기': 6, '확정': 2, '반려': 1} | 최종 정비 등급은 검사관·정비감독관의 결재로 확정되며 AI는 확정 권한이 없다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 08. 오염물/진흙 혼입 사진 대량 판정 스트레스 테스트 및 한계치 도출\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"오염물 혼입 사진 대량 판정 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오염물·진흙이 섞인 사진을 대량 판정하는 스트레스 테스트를 수행하고 판정 한계치를 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **진흙 혼입 비율을 0~50%로 올린 사진 묶음과 흐림·센서 잡음·저조도+진흙 결합 조건에서 등급 일치율, 재촬영 요구율, 자동 처리 비율, 경보 정확도를 측정하고 품질 게이트의 효과를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/dashboard/qlt03_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n",
"\n",
"\n",
"- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n",
"\n",
"\n",
"- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"진흙·오염물 혼입 비율을 0~50%로 올린 사진 수백 장과 흐림·센서 잡음·저조도 결합 사진을 일괄 판정하는 스트레스 테스트로 오염 사진 판정 한계치(자동 처리 가능 가림 비율)와 경보 정확도, 대량 처리 시간·대시보드 로딩 시간을 측정하고 품질 게이트(재촬영·검사관 확인)의 효과를 확인한다.\"\"\"\n",
"import time\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n",
"from sklearn.model_selection import cross_val_predict\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 8] 오염물/진흙 혼입 사진 대량 판정 스트레스 테스트 및 한계치 도출 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 8] 오염물/진흙 혼입 사진 대량 판정 스트레스 테스트 및 한계치 도출 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n",
"def fit_pipeline(meta, F):\n",
" \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n",
" clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n",
" P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n",
" clf.fit(F, meta[\"cls\"])\n",
" reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n",
" return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n",
"\n",
"\n",
"def predict_pipeline(pl, F):\n",
" X = F[pl[\"cols\"]]\n",
" P = pl[\"clf\"].predict_proba(X)\n",
" idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n",
" return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n",
"# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n",
"def mud_est(img):\n",
" \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n",
" sm = ndi.uniform_filter(img, size=(3, 3, 1))\n",
" s = sm.sum(axis=2) + 1e-6\n",
" rr, gg = sm[..., 0] / s, sm[..., 1] / s\n",
" return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n",
"\n",
"\n",
"def confidence(P, idx, F, mud):\n",
" \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n",
" bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n",
" 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)\n",
" c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n",
" return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n",
"\n",
"mtr, itr, _ = make_images(600, seed=1)\n",
"pl = fit_pipeline(mtr, feature_table(itr))\n",
"\n",
"\n",
"def run(imgs, truth):\n",
" \"\"\"사진 묶음을 판정해 게이트 적용 전·후 신뢰도와 경보(등급 2 이상 = 정비 경보)를 만든다.\"\"\"\n",
" t0 = time.perf_counter()\n",
" F = feature_table(imgs)\n",
" t1 = time.perf_counter()\n",
" cls, P, idx, _ = predict_pipeline(pl, F)\n",
" t2 = time.perf_counter()\n",
" mud = np.array([mud_est(x) for x in imgs])\n",
" raw_conf = confidence(P, idx, F, np.zeros(len(imgs)))\n",
" conf = confidence(P, idx, F, mud)\n",
" t3 = time.perf_counter()\n",
" g = grade_of(idx)\n",
" 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)\n",
"\n",
"\n",
"# ---------------- 1) 진흙 혼입 비율 스윕 ----------------\n",
"rows, tm = [], []\n",
"for mudr in (0.0, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50):\n",
" m, im, _ = make_images(120, seed=20 + int(mudr * 100), mud=mudr)\n",
" r, t = run(im, m)\n",
" tm.append(t)\n",
" ok = r[\"true\"] == r[\"ai\"]\n",
" auto = (r[\"conf\"] >= 80) & ~r[\"retake\"] # 품질 게이트를 통과해 자동 처리(검사관 간이 승인)로 가는 사진\n",
" alarm_ok = ((r[\"ai\"] >= 2) == (r[\"true\"] >= 2))\n",
" ung = r[\"raw_conf\"] >= 80\n",
" 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()))\n",
"sw = pd.DataFrame(rows, columns=[\"진흙 비율\", \"장수\", \"등급 일치율(전체)\", \"평균 신뢰도\", \"재촬영 요구율\", \"자동 처리 비율\", \"경보 정확도(게이트 후)\", \"경보 정확도(게이트 없이 80 이상)\", \"게이트 없이 80 이상 비율\"])\n",
"print(\"[1] 진흙 혼입 비율별 판정(교육용 가상 사진, 비율당 120장)\")\n",
"pc = lambda v: \"-\" if pd.isna(v) else f\"{v:.0%}\"\n",
"print(sw.assign(**{c: sw[c].map(pc) for c in sw.columns if c not in (\"진흙 비율\", \"장수\", \"평균 신뢰도\")}).round(1).to_string(index=False))\n",
"bad = sw[sw[\"경보 정확도(게이트 후)\"] < .95][\"진흙 비율\"].tolist()\n",
"print(f\" 경보 정확도가 95% 미만으로 처음 떨어지는 진흙 비율: {bad[0] if bad else '없음'} (진흙 0%에서는 자동 처리 {sw.iloc[0]['자동 처리 비율']:.0%}, 경보 정확도 {sw.iloc[0]['경보 정확도(게이트 후)']:.0%})\")\n",
"print(f\" 게이트가 없으면 진흙 20%에서도 신뢰도 80 이상이 {sw.iloc[3]['게이트 없이 80 이상 비율']:.0%} 남고 그 경보 정확도는 {sw.iloc[3]['경보 정확도(게이트 없이 80 이상)']:.0%}로 낮다. 품질 게이트는 이런 사진을 자동 처리에서 빼 검사관 확인으로 보낸다.\")\n",
"\n",
"# ---------------- 2) 복합 열화: 흐림, 센서 잡음, 저조도+진흙 ----------------\n",
"rng = np.random.default_rng(3)\n",
"cases = {}\n",
"m0, i0, _ = make_images(120, seed=40)\n",
"cases[\"정상\"] = (m0, i0)\n",
"cases[\"흐림(초점 불량)\"] = (m0, np.clip(np.stack([ndi.gaussian_filter(x, sigma=(1.8, 1.8, 0)) for x in i0]), 0, 1))\n",
"cases[\"센서 잡음 +0.08\"] = (m0, np.clip(i0 + rng.normal(0, 0.08, i0.shape), 0, 1).astype(np.float32))\n",
"m1, i1, _ = make_images(120, seed=41, mud=0.2, dark=True)\n",
"cases[\"저조도+진흙 20%\"] = (m1, i1)\n",
"rows = []\n",
"for name, (m, im) in cases.items():\n",
" r, _ = run(im, m)\n",
" ok = r[\"true\"] == r[\"ai\"]\n",
" auto = (r[\"conf\"] >= 80) & ~r[\"retake\"]\n",
" am = ((r[\"ai\"] >= 2) == (r[\"true\"] >= 2))\n",
" rows.append((name, ok.mean(), r[\"retake\"].mean(), auto.mean(), am[auto].mean() if auto.any() else np.nan, (r[\"true\"] > r[\"ai\"]).mean()))\n",
"cc = pd.DataFrame(rows, columns=[\"조건\", \"등급 일치율\", \"재촬영 요구율\", \"자동 처리 비율\", \"자동 처리 경보 정확도\", \"과소 판정 비율\"])\n",
"print(\"\\n[2] 복합 열화 조건(120장)\\n\" + cc.assign(**{c: cc[c].map(pc) for c in cc.columns[1:]}).to_string(index=False))\n",
"print(f\" 흐림 사진은 게이트가 직접 잡지 못해 자동 처리 {cc.iloc[1]['자동 처리 비율']:.0%} 중 경보 정확도 {cc.iloc[1]['자동 처리 경보 정확도']:.0%}로 95%에 못 미친다 -> 초점·촬영 거리 가이드 보완이 필요하다.\")\n",
"\n",
"# ---------------- 3) 대량 처리 시간과 대시보드 로딩 ----------------\n",
"tt = np.array(tm).mean(axis=0) / 120\n",
"per = tt.sum()\n",
"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}장/초)\")\n",
"for n in (1000, 5000, 20000):\n",
" print(f\" {n:>6}장 일괄 판정 예상: {n * per:.0f}초 ({n * per / 60:.1f}분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\")\n",
"\n",
"\n",
"def dashboard(df):\n",
" t0 = time.perf_counter()\n",
" summ = df.groupby(\"ai\").agg(건수=(\"idx\", \"size\"), 평균신뢰도=(\"conf\", \"mean\"))\n",
" low = df.sort_values(\"conf\").head(20)\n",
" fg, ax = plt.subplots(1, 2, figsize=(9, 3.5))\n",
" ax[0].bar(summ.index.astype(str), summ[\"건수\"]); ax[1].hist(df[\"conf\"], bins=20)\n",
" fg.canvas.draw(); plt.close(fg)\n",
" return time.perf_counter() - t0, len(low)\n",
"\n",
"\n",
"base, _ = run(cases[\"정상\"][1], cases[\"정상\"][0])\n",
"loads = []\n",
"for n in (300, 3000, 30000, 300000):\n",
" big = pd.concat([base] * (n // len(base) + 1), ignore_index=True).head(n)\n",
" loads.append((n, dashboard(big)[0]))\n",
"print(\" 대시보드 로딩 시간(요약 집계 + 상위 20개 목록 + 차트 렌더링):\", \", \".join(f\"{n}건 {s:.2f}초\" for n, s in loads))\n",
"print(f\" 3000건 기준 로딩 {loads[1][1]:.2f}초(목표 2초 이내: {'달성' if loads[1][1] <= 2 else '미달'}), 30만 건에서도 {loads[3][1]:.2f}초. 화면은 요약과 신뢰도 낮은 사진 우선 목록만 먼저 표시한다.\")\n",
"\n",
"# ---------------- 4) 결론 ----------------\n",
"print(\"\\n[4] 한계치 요약\")\n",
"print(f\" - 자동 처리(검사관 간이 승인) 후보는 가림이 없는 사진 중심이며, 진흙 {bad[0] if bad else '5%'} 이상부터 경보 정확도 95% 목표를 지키지 못해 재촬영 또는 검사관 정밀 판독으로 보낸다\")\n",
"print(f\" - 정상 사진도 {1 - sw.iloc[0]['자동 처리 비율']:.0%}는 신뢰도 80 미만이라 검사관 확인 대상이다. 경보 정확도는 자동 처리 사진에 한해 판단하고 자동 처리 비율을 함께 보고한다\")\n",
"print(\" - 판정·경보는 검사관 확인의 보조 수단이며 최종 정비 등급은 사람이 확정한다. 모든 사진은 교육용 가상 자료이다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))\n",
"x = np.arange(len(sw))\n",
"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\")\n",
"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 (%)\")\n",
"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 (%)\")\n",
"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\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1] 진흙 혼입 비율별 판정(교육용 가상 사진, 비율당 120장)\n",
"진흙 비율 장수 등급 일치율(전체) 평균 신뢰도 재촬영 요구율 자동 처리 비율 경보 정확도(게이트 후) 경보 정확도(게이트 없이 80 이상) 게이트 없이 80 이상 비율\n",
" 0% 120 90% 86.4 0% 73% 100% 100% 73%\n",
" 5% 120 65% 67.3 1% 12% 93% 94% 26%\n",
" 10% 120 53% 63.3 12% 3% 75% 88% 27%\n",
" 20% 120 42% 63.9 66% 0% - 73% 25%\n",
" 30% 120 64% 62.1 88% 0% - 77% 11%\n",
" 40% 120 59% 59.8 99% 0% - 38% 7%\n",
" 50% 120 68% 64.9 100% 0% - 79% 16%\n",
" 경보 정확도가 95% 미만으로 처음 떨어지는 진흙 비율: 5% (진흙 0%에서는 자동 처리 73%, 경보 정확도 100%)\n",
" 게이트가 없으면 진흙 20%에서도 신뢰도 80 이상이 25% 남고 그 경보 정확도는 73%로 낮다. 품질 게이트는 이런 사진을 자동 처리에서 빼 검사관 확인으로 보낸다.\n",
"\n",
"[2] 복합 열화 조건(120장)\n",
" 조건 등급 일치율 재촬영 요구율 자동 처리 비율 자동 처리 경보 정확도 과소 판정 비율\n",
" 정상 96% 0% 75% 100% 4%\n",
" 흐림(초점 불량) 84% 0% 62% 91% 11%\n",
"센서 잡음 +0.08 66% 8% 8% 100% 8%\n",
" 저조도+진흙 20% 42% 100% 0% - 12%\n",
" 흐림 사진은 게이트가 직접 잡지 못해 자동 처리 62% 중 경보 정확도 91%로 95%에 못 미친다 -> 초점·촬영 거리 가이드 보완이 필요하다.\n",
"\n",
"[3] 사진 1장당 처리 시간(1개 코어 가정): 분할·특징 4.1ms, 판정 0.16ms, 오염 추정·신뢰도 0.3ms, 합계 4.5ms (223장/초)\n",
" 1000장 일괄 판정 예상: 4초 (0.1분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n",
" 5000장 일괄 판정 예상: 22초 (0.4분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n",
" 20000장 일괄 판정 예상: 90초 (1.5분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n",
" 대시보드 로딩 시간(요약 집계 + 상위 20개 목록 + 차트 렌더링): 300건 0.19초, 3000건 0.10초, 30000건 0.14초, 300000건 0.17초\n",
" 3000건 기준 로딩 0.10초(목표 2초 이내: 달성), 30만 건에서도 0.17초. 화면은 요약과 신뢰도 낮은 사진 우선 목록만 먼저 표시한다.\n",
"\n",
"[4] 한계치 요약\n",
" - 자동 처리(검사관 간이 승인) 후보는 가림이 없는 사진 중심이며, 진흙 5% 이상부터 경보 정확도 95% 목표를 지키지 못해 재촬영 또는 검사관 정밀 판독으로 보낸다\n",
" - 정상 사진도 27%는 신뢰도 80 미만이라 검사관 확인 대상이다. 경보 정확도는 자동 처리 사진에 한해 판단하고 자동 처리 비율을 함께 보고한다\n",
" - 판정·경보는 검사관 확인의 보조 수단이며 최종 정비 등급은 사람이 확정한다. 모든 사진은 교육용 가상 자료이다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 09. AAR 기반 외관 오판 사례 분석 및 컴퓨터 비전 모델 파라미터 재보정\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"AAR 기반 외관 오판 사례를 분석하고 컴퓨터 비전 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 외관 오판 사례를 분석하고 컴퓨터 비전 모델 파라미터를 재보정하며 도입 효과를 정량화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **운영 분포(저조도·진흙·정상 혼합)의 판정 로그에서 오판을 오염 가림·저조도·작은 결함·유형 혼동·경계 근처로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `reports/qlt03_roi_analysis.md` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n",
"\n",
"\n",
"- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n",
"\n",
"\n",
"- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"운영 기간 외관 판정 로그로 AAR을 수행해 오판 사례를 원인별(오염 가림·저조도·작은 결함·경계 근처·유형 혼동)로 분류하고, 분할 임계값·학습 데이터·등급 경계(과소 판정 손실 반영)를 재보정해 보류 구간에서 개선을 검증한 뒤 비용 절감액·시간 단축률·투자 회수 기간 3대 효과 지표를 산식과 민감도로 산출한다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n",
"from sklearn.model_selection import cross_val_predict\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 9] AAR 기반 외관 오판 사례 분석 및 컴퓨터 비전 모델 파라미터 재보정 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 9] AAR 기반 외관 오판 사례 분석 및 컴퓨터 비전 모델 파라미터 재보정 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n",
"def fit_pipeline(meta, F):\n",
" \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n",
" clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n",
" P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n",
" clf.fit(F, meta[\"cls\"])\n",
" reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n",
" return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n",
"\n",
"\n",
"def predict_pipeline(pl, F):\n",
" X = F[pl[\"cols\"]]\n",
" P = pl[\"clf\"].predict_proba(X)\n",
" idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n",
" return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n",
"# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n",
"def mud_est(img):\n",
" \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n",
" sm = ndi.uniform_filter(img, size=(3, 3, 1))\n",
" s = sm.sum(axis=2) + 1e-6\n",
" rr, gg = sm[..., 0] / s, sm[..., 1] / s\n",
" return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n",
"\n",
"\n",
"def confidence(P, idx, F, mud):\n",
" \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n",
" bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n",
" 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)\n",
" c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n",
" return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n",
"\n",
"mtr, itr, _ = make_images(600, seed=1)\n",
"Ftr = feature_table(itr)\n",
"pl0 = fit_pipeline(mtr, Ftr)\n",
"LOSS_UNDER, LOSS_OVER = 20.0, 6.0 # 과소/과대 판정 1등급당 손실(만원, 가정)\n",
"\n",
"\n",
"def ops(seed, n):\n",
" \"\"\"운영 사진 묶음: 저조도 1/3 + 진흙 5% 1/3 + 정상 1/3 (학습 때와 분포가 다르다).\"\"\"\n",
" 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)]\n",
" 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])\n",
"\n",
"\n",
"def loss(true, g):\n",
" d = np.asarray(g) - np.asarray(true)\n",
" return np.where(d < 0, -d * LOSS_UNDER, d * LOSS_OVER)\n",
"\n",
"\n",
"def grade_shift(idx, delta):\n",
" \"\"\"과소 판정을 줄이기 위해 등급 경계를 delta만큼 낮춘다(35 -> 35-delta, 65 -> 65-delta).\"\"\"\n",
" return np.where(np.asarray(idx) >= 65 - delta, 3, np.where(np.asarray(idx) >= 35 - delta, 2, 1))\n",
"\n",
"\n",
"def evaluate(pl, F, m, ims, delta=0):\n",
" cls, P, idx, _ = predict_pipeline(pl, F)\n",
" mud = np.array([mud_est(x) for x in ims])\n",
" conf = confidence(P, idx, F, mud)\n",
" g = grade_shift(idx, delta)\n",
" auto = (conf >= 80) & (mud < 0.03) & (F[\"gain\"].values >= 0.17)\n",
" ok = g == m[\"grade\"].values\n",
" 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()),\n",
" \"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}\n",
"\n",
"\n",
"# ---------------- 1) AAR: 운영 로그 분석(이동 구간 A, 360장) ----------------\n",
"mA, iA, kA = ops(60, 360)\n",
"rng = np.random.default_rng(0)\n",
"perm = rng.permutation(len(mA))\n",
"FA = feature_table(iA)\n",
"ev = evaluate(pl0, FA, mA, iA)\n",
"print(f\"[1] AAR 대상 운영 사진 {len(mA)}장(저조도·진흙 5%·정상 혼합): 등급 일치율 {ev['ok'].mean():.1%}, 손상도 MAE {ev['mae']:.2f}, 과소 판정 {ev['under']}장, 장당 오판 손실 {ev['loss']:.2f}만원\")\n",
"wrong = np.where(~ev[\"ok\"])[0]\n",
"\n",
"\n",
"def cause(i):\n",
" if ev[\"mud\"][i] >= 0.01: return \"오염 가림\"\n",
" if FA[\"gain\"].iloc[i] < 0.2: return \"저조도\"\n",
" if mA[\"cls\"][i] != \"none\" and mA[\"area_frac\"][i] < 0.02: return \"작은 결함 누락·과소\"\n",
" if ev[\"cls\"][i] != mA[\"cls\"][i]: return \"결함 유형 혼동\"\n",
" if min(abs(ev[\"idx\"][i] - 35), abs(ev[\"idx\"][i] - 65)) < 6: return \"등급 경계 근처\"\n",
" return \"기타\"\n",
"\n",
"\n",
"cs = pd.Series([cause(i) for i in wrong]).value_counts()\n",
"print(f\" 오판 {len(wrong)}장의 원인 분류:\", \", \".join(f\"{k} {v}장({v / len(wrong):.0%})\" for k, v in cs.items()))\n",
"print(f\" 오판 중 과소 판정(실제보다 낮은 등급) {ev['under']}장 / 과대 {int((ev['g'] > mA['grade'].values).sum())}장 -> 과소 판정이 손실 단가가 {LOSS_UNDER / LOSS_OVER:.1f}배 높아 우선 교정 대상\")\n",
"\n",
"# ---------------- 2) 재보정: 분할 임계값 -> 운영 데이터 재학습 -> 등급 경계 이동 ----------------\n",
"A1, A2 = perm[:240], perm[240:]\n",
"cache = [correct(iA[i]) for i in A1]\n",
"\n",
"\n",
"def mask_from(c, chroma, dt, rt, rim=1.45):\n",
" dark, rust, r = c < dt, (chroma > rt) & (c > 0.45), c > rim\n",
" m = dark | rust | ndi.binary_dilation(r, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" return ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
"\n",
"\n",
"grid = []\n",
"for dt in (0.6, 0.7, 0.75, 0.8):\n",
" for rt in (0.44, 0.5, 0.54):\n",
" 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)])\n",
" grid.append((dt, rt, iou))\n",
"gd = pd.DataFrame(grid, columns=[\"dark 임계값\", \"rust 임계값\", \"평균 IoU\"])\n",
"bst = gd.loc[gd[\"평균 IoU\"].idxmax()]\n",
"cur = gd[(gd[\"dark 임계값\"] == 0.7) & (gd[\"rust 임계값\"] == 0.5)][\"평균 IoU\"].iloc[0]\n",
"print(f\"\\n[2] 분할 임계값 재보정(운영 사진 240장 IoU): 현행(0.70, 0.50) {cur:.3f} -> 최적({bst['dark 임계값']:.2f}, {bst['rust 임계값']:.2f}) {bst['평균 IoU']:.3f}\")\n",
"SEGP[\"dark\"], SEGP[\"rust\"] = float(bst[\"dark 임계값\"]), float(bst[\"rust 임계값\"])\n",
"FA2 = feature_table(iA)\n",
"mm = pd.concat([mtr, mA.iloc[A1]], ignore_index=True)\n",
"pl1 = fit_pipeline(mm, pd.concat([Ftr, FA2.iloc[A1]], ignore_index=True)) # 운영 분포 사진 240장을 추가 학습\n",
"ev2 = evaluate(pl1, FA2.iloc[A2].reset_index(drop=True), mA.iloc[A2].reset_index(drop=True), iA[A2])\n",
"costs = []\n",
"for dlt in (0, 2, 4, 6, 8):\n",
" r = evaluate(pl1, FA2.iloc[A2].reset_index(drop=True), mA.iloc[A2].reset_index(drop=True), iA[A2], delta=dlt)\n",
" costs.append((dlt, r[\"loss\"], r[\"under\"], r[\"ok\"].mean()))\n",
"cd = pd.DataFrame(costs, columns=[\"경계 이동 delta\", \"장당 손실(만원)\", \"과소 판정\", \"일치율\"])\n",
"best_d = int(cd.loc[cd[\"장당 손실(만원)\"].idxmin(), \"경계 이동 delta\"])\n",
"print(\" 등급 경계 이동(보정용 120장, 과소 판정 손실 반영)\\n\" + cd.round(3).to_string(index=False))\n",
"if best_d == 0:\n",
" print(\" 경계를 낮추면 과대 판정이 늘어 손실이 오히려 커지므로 경계 이동은 채택하지 않는다(delta=0 유지). 과소 판정은 경계가 아니라 오염 가림에서 주로 발생한다.\")\n",
"else:\n",
" print(f\" 손실이 가장 작은 경계 이동 delta={best_d}점 채택(과소 판정을 줄이는 방향이며 사람 승인 후 적용)\")\n",
"\n",
"# ---------------- 3) 보류 구간 B(360장, 재보정에 쓰지 않음) 검증 ----------------\n",
"mB, iB, kB = ops(70, 360)\n",
"SEGP[\"dark\"], SEGP[\"rust\"] = 0.7, 0.5\n",
"FB0 = feature_table(iB)\n",
"e0 = evaluate(pl0, FB0, mB, iB)\n",
"SEGP[\"dark\"], SEGP[\"rust\"] = float(bst[\"dark 임계값\"]), float(bst[\"rust 임계값\"])\n",
"FB1 = feature_table(iB)\n",
"e1 = evaluate(pl1, FB1, mB, iB, delta=best_d)\n",
"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\"]]},\n",
" index=[\"등급 일치율\", \"손상도 MAE\", \"과소 판정(장)\", \"장당 오판 손실(만원)\", \"자동 처리 건수\", \"자동 처리 일치율\"])\n",
"print(\"\\n[3] 보류 구간 B 검증\\n\" + tab.round(3).to_string())\n",
"print(f\" 오판 손실 {1 - e1['loss'] / e0['loss']:.1%} 감소, 과소 판정 {e0['under']} -> {e1['under']}장. 오염 가림 사진은 정보 자체가 가려져 재보정으로 해결되지 않으므로 재촬영이 필요하다.\")\n",
"\n",
"# ---------------- 4) ROI 3대 지표(교육용 가정) ----------------\n",
"N_YR, MIN_FULL, MIN_QUICK = 20000, 15.0, 4.0 # 연 판정 사진 수, 정밀 판독/간이 승인 소요(분)\n",
"WAGE, INV, OPEX = 1.2, 2500.0, 150.0 # 검사관 인건비(만원/시), 도입 비용, 연 운영비(만원)\n",
"full_b, full_a = 1 - e0[\"auto_n\"] / len(mB), 1 - e1[\"auto_n\"] / len(mB)\n",
"\n",
"\n",
"def roi(loss_b, loss_a, fb, fa, n=N_YR, inv=INV):\n",
" t_b = fb * MIN_FULL + (1 - fb) * MIN_QUICK\n",
" t_a = fa * MIN_FULL + (1 - fa) * MIN_QUICK\n",
" save_loss = (loss_b - loss_a) * n\n",
" save_time = (MIN_FULL - t_a) / 60 * WAGE * n # 관행(전수 정밀 판독) 대비 판독 인건비 절감\n",
" save = save_loss + save_time - OPEX\n",
" return save, 1 - t_a / MIN_FULL, inv / max(save / 12, 1e-9), save_loss, save_time\n",
"\n",
"\n",
"sv, tr, pb, sl, st = roi(e0[\"loss\"], e1[\"loss\"], full_b, full_a)\n",
"print(f\"\\n[4] ROI 3대 지표 [교육용 가정: 연 {N_YR}장, 정밀 판독 {MIN_FULL:.0f}분, 간이 승인 {MIN_QUICK:.0f}분, 인건비 {WAGE}만원/시, 도입 {INV:.0f}만원, 운영 {OPEX:.0f}만원/년]\")\n",
"print(f\" (1) 연간 비용 절감액 {sv:,.0f}만원(오판 손실 감소 {sl:,.0f} + 판독 인건비 {st:,.0f} - 운영비) (2) 판독 시간 단축률 {tr:.1%}(관행 전수 정밀 판독 대비) (3) 투자 회수 기간 {pb:.1f}개월\")\n",
"print(\" 산식: 절감액 = (재보정 전 - 후 장당 오판 손실) x 연 장수 + (정밀 판독 - 평균 판독 시간)/60 x 인건비 x 연 장수 - 운영비\")\n",
"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)))\n",
"print(\" 비관(재보정 효과 없음, 판독 시간 절감만):\", f\"절감 {roi(e0['loss'], e0['loss'], full_b, full_a)[0]:,.0f}만원, 회수 {roi(e0['loss'], e0['loss'], full_b, full_a)[2]:.0f}개월\")\n",
"print(\" 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정된 임계값·경계는 정비감독관 승인 후 적용한다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))\n",
"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)\")\n",
"ax[1].plot(cd[\"경계 이동 delta\"], cd[\"장당 손실(만원)\"], \"o-\"); ax[1].set_title(\"Loss per photo vs grade-boundary shift\"); ax[1].set_xlabel(\"delta\")\n",
"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\")\n",
"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\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[1] AAR 대상 운영 사진 360장(저조도·진흙 5%·정상 혼합): 등급 일치율 83.1%, 손상도 MAE 8.72, 과소 판정 22장, 장당 오판 손실 1.99만원\n",
" 오판 61장의 원인 분류: 오염 가림 29장(48%), 결함 유형 혼동 9장(15%), 저조도 8장(13%), 기타 8장(13%), 등급 경계 근처 7장(11%)\n",
" 오판 중 과소 판정(실제보다 낮은 등급) 22장 / 과대 39장 -> 과소 판정이 손실 단가가 3.3배 높아 우선 교정 대상\n",
"\n",
"[2] 분할 임계값 재보정(운영 사진 240장 IoU): 현행(0.70, 0.50) 0.495 -> 최적(0.75, 0.44) 0.528\n",
" 등급 경계 이동(보정용 120장, 과소 판정 손실 반영)\n",
" 경계 이동 delta 장당 손실(만원) 과소 판정 일치율\n",
" 0 1.483 5 0.850\n",
" 2 1.733 5 0.808\n",
" 4 1.933 5 0.775\n",
" 6 2.133 5 0.742\n",
" 8 1.950 3 0.733\n",
" 경계를 낮추면 과대 판정이 늘어 손실이 오히려 커지므로 경계 이동은 채택하지 않는다(delta=0 유지). 과소 판정은 경계가 아니라 오염 가림에서 주로 발생한다.\n",
"\n",
"[3] 보류 구간 B 검증\n",
" 재보정 전 재보정 후\n",
"등급 일치율 0.819 0.853\n",
"손상도 MAE 7.579 6.559\n",
"과소 판정(장) 26.000 25.000\n",
"장당 오판 손실(만원) 2.194 2.022\n",
"자동 처리 건수 150.000 151.000\n",
"자동 처리 일치율 0.980 0.980\n",
" 오판 손실 7.8% 감소, 과소 판정 26 -> 25장. 오염 가림 사진은 정보 자체가 가려져 재보정으로 해결되지 않으므로 재촬영이 필요하다.\n",
"\n",
"[4] ROI 3대 지표 [교육용 가정: 연 20000장, 정밀 판독 15분, 간이 승인 4분, 인건비 1.2만원/시, 도입 2500만원, 운영 150만원/년]\n",
" (1) 연간 비용 절감액 5,140만원(오판 손실 감소 3,444 + 판독 인건비 1,846 - 운영비) (2) 판독 시간 단축률 30.8%(관행 전수 정밀 판독 대비) (3) 투자 회수 기간 5.8개월\n",
" 산식: 절감액 = (재보정 전 - 후 장당 오판 손실) x 연 장수 + (정밀 판독 - 평균 판독 시간)/60 x 인건비 x 연 장수 - 운영비\n",
" 장수 민감도: 5000장 -> 절감 1,173만원, 회수 26개월; 10000장 -> 절감 2,495만원, 회수 12개월; 20000장 -> 절감 5,140만원, 회수 6개월; 40000장 -> 절감 10,430만원, 회수 3개월\n",
" 비관(재보정 효과 없음, 판독 시간 절감만): 절감 1,696만원, 회수 18개월\n",
" 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정된 임계값·경계는 정비감독관 승인 후 적용한다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 10. 장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구 통합 완성\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 결함 사진 기반 AI 외관 검사와 손상도 판정 도구를 통합하고 자동 재학습·운영 문서·자체 점검을 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **신규 사진 240장을 추가해 정합성을 검증하고 분포 변화(PSI)를 점검한 뒤 챌린저 모델을 학습해 검증 MAE가 3% 이상 개선될 때만 승격 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/qlt03_operations_guide.md 및 pipeline/qlt03_retrain.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 지속 가능한 운영 가능성 및 국방 군수 정보체계와의 연동 용이성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 자동 재학습 파이프라인 정상 실행, 최종 문서 100% 완비\n",
"\n",
"\n",
"- 진행 기준: 최종 인도물 검수 및 프로젝트 완료 발표자료 완성\n",
"\n",
"\n",
"- 검증 방법: End-to-End 전체 파이프라인 자동화 실행 및 최종 검수\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"신규 사진 갱신, 정합성 검증, 분포 변화(PSI) 점검, 자동 재학습(챔피언/챌린저), 결함 판정·손상도·정비 등급 산출과 검사 대기·재촬영 분리, 제원 불일치 환류까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다.\"\"\"\n",
"import importlib\n",
"import json\n",
"import sys\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n",
"from sklearn.linear_model import LinearRegression\n",
"from sklearn.model_selection import cross_val_predict\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n",
"# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n",
"# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n",
"# ---------------------------------------------------------------------------\n",
"from scipy import ndimage as ndi\n",
"\n",
"SZ = 64\n",
"CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n",
"KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n",
"CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n",
"GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n",
"YY, XX = np.mgrid[0:SZ, 0:SZ]\n",
"\n",
"\n",
"def grade_of(idx):\n",
" # [TODO : 학생 실습 10] 장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구 통합 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def blob(rng, cy, cx, r, rough=0.35):\n",
" \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n",
" # [TODO : 학생 실습 10] 장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구 통합 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def draw(rng, cls, sev):\n",
" \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n",
" m = np.zeros((SZ, SZ), bool)\n",
" if cls == \"crack\":\n",
" y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n",
" for _ in range(int(20 + 40 * sev)):\n",
" ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n",
" if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n",
" m[int(y), int(x)] = True\n",
" m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n",
" elif cls == \"corrosion\":\n",
" for _ in range(int(rng.integers(1, 4))):\n",
" m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n",
" elif cls == \"damage\":\n",
" m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n",
" elif cls == \"leak\":\n",
" x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n",
" w, ln = 2 + 4 * sev, 12 + 26 * sev\n",
" m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n",
" return m\n",
"\n",
"\n",
"def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n",
" \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n",
" for i in range(n):\n",
" cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n",
" sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n",
" base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n",
" img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n",
" m = draw(rng, cls, sev)\n",
" a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n",
" if cls == \"crack\":\n",
" col = img * rng.uniform(0.2, 0.45)\n",
" elif cls == \"corrosion\":\n",
" col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n",
" elif cls == \"damage\":\n",
" col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n",
" rim = m & ~ndi.binary_erosion(m, iterations=2)\n",
" a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n",
" elif cls == \"leak\":\n",
" col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n",
" else:\n",
" col = img\n",
" img = img * (1 - a) + col * a\n",
" if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n",
" st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n",
" al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n",
" img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n",
" if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n",
" y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n",
" for t in np.linspace(0, 25, 40):\n",
" yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n",
" if 0 <= yy < SZ and 0 <= xx < SZ:\n",
" img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n",
" if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n",
" mm = np.zeros((SZ, SZ), bool)\n",
" while mm.mean() < mud:\n",
" mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n",
" img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n",
" gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n",
" ang = rng.uniform(0, 2 * np.pi)\n",
" light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n",
" imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n",
" masks[i] = m\n",
" area = float(m.mean())\n",
" 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)\n",
" idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n",
" rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n",
" \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n",
" meta = pd.DataFrame(rows)\n",
" # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n",
" age = rng.integers(1, 25, n)\n",
" meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n",
" meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n",
" meta[\"mfg_year\"] = 2026 - age\n",
" meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n",
" meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n",
" reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n",
" meta[\"reg_grade\"] = reg\n",
" meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n",
" return meta, imgs, masks\n",
"# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n",
"def correct(img):\n",
" \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n",
" g = img.mean(axis=2)\n",
" A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n",
" keep = np.ones(SZ * SZ, bool)\n",
" for _ in range(3):\n",
" coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n",
" plane = (A @ coef).reshape(SZ, SZ)\n",
" keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n",
" if keep.sum() < 200:\n",
" keep[:] = True\n",
" c = g / np.maximum(plane, 1e-3)\n",
" chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n",
" return c, chroma\n",
"\n",
"\n",
"SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n",
"\n",
"\n",
"def segment(img):\n",
" \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n",
" c, chroma = correct(img)\n",
" dark = c < SEGP[\"dark\"]\n",
" rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n",
" rim = c > SEGP[\"rim\"]\n",
" m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n",
" m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n",
" return m, c, chroma, dark, rust, rim\n",
"\n",
"\n",
"def extract(img):\n",
" \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n",
" m, c, chroma, dark, rust, rim = segment(img)\n",
" lab, n = ndi.label(m)\n",
" area = float(m.sum())\n",
" if n:\n",
" sizes = ndi.sum(m, lab, range(1, n + 1))\n",
" k = int(np.argmax(sizes)) + 1\n",
" sl = ndi.find_objects(lab)[k - 1]\n",
" h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n",
" big = float(sizes.max())\n",
" elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n",
" else:\n",
" big, elong, fill = 0.0, 1.0, 0.0\n",
" gy, gx = np.gradient(c)\n",
" mag = np.hypot(gx, gy)\n",
" inside = m.sum() > 0\n",
" return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n",
" \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n",
" \"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)),\n",
" \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n",
" \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n",
" \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n",
" \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n",
" \"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}\n",
"\n",
"\n",
"def feature_table(imgs):\n",
" return pd.DataFrame([extract(im) for im in imgs])\n",
"# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n",
"def fit_pipeline(meta, F):\n",
" \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n",
" clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n",
" P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n",
" clf.fit(F, meta[\"cls\"])\n",
" reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n",
" return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n",
"\n",
"\n",
"def predict_pipeline(pl, F):\n",
" X = F[pl[\"cols\"]]\n",
" P = pl[\"clf\"].predict_proba(X)\n",
" idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n",
" return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n",
"# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n",
"def mud_est(img):\n",
" \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n",
" sm = ndi.uniform_filter(img, size=(3, 3, 1))\n",
" s = sm.sum(axis=2) + 1e-6\n",
" rr, gg = sm[..., 0] / s, sm[..., 1] / s\n",
" return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n",
"\n",
"\n",
"def confidence(P, idx, F, mud):\n",
" \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n",
" bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n",
" 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)\n",
" c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n",
" return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n",
"\n",
"libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n",
"env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n",
"print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)}\")\n",
"\n",
"# ---------------- 1) 신규 사진 갱신·정합성 검증(미션 1~2 요약) ----------------\n",
"m_old, i_old, k_old = make_images(600, seed=1) # 기존 600장\n",
"parts = [make_images(80, seed=80, dark=True), make_images(80, seed=81, mud=0.05), make_images(80, seed=82)]\n",
"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])\n",
"m_new[\"img_id\"] = [\"NEW\" + x[3:] for x in m_new[\"img_id\"]] # 신규 사진 ID 구분\n",
"meta = pd.concat([m_old, m_new], ignore_index=True); imgs = np.concatenate([i_old, i_new]); masks = np.concatenate([k_old, k_new])\n",
"chk = {\"사진 ID 유일\": not meta[\"img_id\"].duplicated().any(), \"결측 0(제원·라벨)\": int(meta[[\"equip_type\", \"mfg_year\", \"op_hours\", \"env\", \"reg_grade\"]].isna().sum().sum()) == 0,\n",
" \"화소 범위 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()),\n",
" \"마스크 면적 = 라벨 면적\": bool(np.allclose(masks.mean(axis=(1, 2)), meta[\"area_frac\"], atol=1e-6)), \"손상도-등급 일치\": bool((grade_of(meta[\"damage_idx\"]) == meta[\"grade\"]).all())}\n",
"print(f\"[1] 신규 240장 포함 {len(meta)}장 정합성 검증 {sum(chk.values())}/{len(chk)} 통과, 긴급 점검 사진 {int(meta['emergency'].sum())}장 보존\")\n",
"F = feature_table(imgs)\n",
"F[\"mud\"] = [mud_est(x) for x in imgs]\n",
"n_old = len(m_old)\n",
"\n",
"# ---------------- 2) 분포 변화 점검(PSI) 및 자동 재학습 ----------------\n",
"def psi(a, b, bins=8):\n",
" q = np.unique(np.quantile(a, np.linspace(0, 1, bins + 1)))\n",
" q[0], q[-1] = -np.inf, np.inf\n",
" pa, pb = np.histogram(a, q)[0] / len(a) + 1e-4, np.histogram(b, q)[0] / len(b) + 1e-4\n",
" return float(np.sum((pa - pb) * np.log(pa / pb)))\n",
"\n",
"\n",
"drift = {c: psi(F[c][:n_old], F[c][n_old:]) for c in [\"gain\", \"c_mean\", \"c_std\", \"rust_frac\", \"area_frac\", \"mud\"]}\n",
"worst = max(drift, key=drift.get)\n",
"print(\"[2] 분포 변화 PSI(기존 600 vs 신규 240):\", \", \".join(f\"{k} {v:.2f}\" for k, v in drift.items()), f\"-> 가장 큰 변화 {worst} (0.2 이상이면 재학습 검토)\")\n",
"rng = np.random.default_rng(0)\n",
"new_idx = rng.permutation(np.arange(n_old, len(meta)))\n",
"tr_new, hold = new_idx[:160], new_idx[160:] # 신규 중 160장은 재학습용, 80장은 검증용\n",
"cols = [c for c in F.columns if c != \"mud\"]\n",
"\n",
"\n",
"def fit_on(ix):\n",
" return fit_pipeline(meta.iloc[ix].reset_index(drop=True), F.iloc[ix][cols].reset_index(drop=True))\n",
"\n",
"\n",
"def score(pl, ix):\n",
" cls, P, idx, _ = predict_pipeline(pl, F.iloc[ix][cols])\n",
" y = meta[\"damage_idx\"].values[ix]\n",
" return float(np.mean(np.abs(y - idx))), float(np.mean(grade_of(idx) == meta[\"grade\"].values[ix]))\n",
"\n",
"\n",
"champ = fit_on(np.arange(n_old))\n",
"chall = fit_on(np.r_[np.arange(n_old), tr_new])\n",
"a, b = score(champ, hold), score(chall, hold)\n",
"promote = (a[0] - b[0]) / a[0] >= 0.03\n",
"drifted = max(drift.values()) >= 0.2\n",
"print(f\" 검증 80장(신규 분포): 챔피언 MAE {a[0]:.2f}·등급 일치 {a[1]:.1%} vs 챌린저 MAE {b[0]:.2f}·등급 일치 {b[1]:.1%} -> {'챌린저 승격' if promote else '기존 모델 유지'}(MAE 3% 이상 개선일 때만 승격)\")\n",
"model = chall if promote else champ\n",
"\n",
"# ---------------- 3) 신규 사진 판정과 검사 대기 분리 ----------------\n",
"Xn = F.iloc[n_old:][cols].reset_index(drop=True)\n",
"cls, P, idx, _ = predict_pipeline(model, Xn)\n",
"mud = F[\"mud\"].values[n_old:]\n",
"conf = confidence(P, idx, Xn, mud)\n",
"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:]})\n",
"res[\"처리\"] = np.select([(mud >= 0.03) | (Xn[\"gain\"].values < 0.17), conf >= 80], [\"재촬영 요구\", \"검사 대기(간이 승인 후보)\"], \"검사 대기(정밀 판독)\")\n",
"res[\"제원 신호\"] = np.abs(res[\"AI 등급\"] - res[\"기록 등급\"]) >= 2\n",
"print(f\"\\n[3] 신규 {len(res)}장 판정 결과 [교육용가상자료] (상위 6건)\")\n",
"print(res.head(6)[[\"사진\", \"판정 유형\", \"손상도\", \"AI 등급\", \"신뢰도\", \"오염\", \"처리\"]].round(2).to_string(index=False))\n",
"vc = res[\"처리\"].value_counts()\n",
"print(\" 처리 구분:\", vc.to_dict(), \"/ 자동 확정 0건(모든 판정은 검사관 확인과 정비감독관 결재 필요)\")\n",
"auto = res[\"처리\"].str.contains(\"간이\")\n",
"print(f\" 간이 승인 후보 {int(auto.sum())}장의 등급 정답 일치율 {(res['AI 등급'] == res['실제 등급'])[auto].mean():.1%}, 전체 일치율 {(res['AI 등급'] == res['실제 등급']).mean():.1%}\")\n",
"print(f\" 제원 환류: AI 등급과 관리 기록 등급이 2단계 이상 다른 {int(res['제원 신호'].sum())}장은 제원 수정 요청 후보(자동 수정 금지, 담당자 확인)\")\n",
"\n",
"# ---------------- 4) 운영 문서 완비율·인터페이스 명세 ----------------\n",
"docs = {\"데이터 사전·스키마(M1)\": 24, \"정제 규칙 11종(M2)\": 11, \"Baseline·EDA(M3)\": 8, \"분할·손상도 모델(M4)\": 24, \"등급·신뢰도 엔진(M5)\": 3, \"판정 인터페이스·설명(M6)\": 5,\n",
" \"승인 절차·권한(M7)\": 4, \"오염 스트레스·한계치(M8)\": 7, \"AAR·ROI(M9)\": 4, \"운영·재학습 절차(M10)\": 6, \"장애 대응\": 5, \"인터페이스 가이드\": 4}\n",
"done = sum(v > 0 for v in docs.values())\n",
"iface = {\"input\": {\"photo\": [\"img_id:str\", \"image:64x64x3 float\", \"equip_type:str\", \"reg_grade:int\"], \"format\": \"image+json\"},\n",
" \"output\": {\"result\": [\"img_id\", \"defect\", \"damage_idx\", \"grade\", \"confidence\", \"mud\", \"state\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n",
"print(f\"\\n[4] 운영 문서 {len(docs)}개 항목 중 작성 완료 {done}개 ({done / len(docs):.0%}) / 인터페이스 명세(JSON):\")\n",
"print(\" \", json.dumps(iface, ensure_ascii=False))\n",
"\n",
"# ---------------- 5) 자체 점검·브리핑 ----------------\n",
"ev_ix = np.arange(n_old, len(meta))\n",
"y = meta[\"damage_idx\"].values[ev_ix]\n",
"fin_mae = float(np.mean(np.abs(y - idx)))\n",
"big = y >= 10\n",
"fin_mape = float(np.mean(np.abs(y[big] - idx[big]) / y[big]) * 100)\n",
"base = LinearRegression().fit(F.iloc[:n_old][[\"area_px\"]], meta[\"damage_idx\"][:n_old])\n",
"base_mae = float(np.mean(np.abs(y - np.clip(base.predict(F.iloc[n_old:][[\"area_px\"]]), 0, 100))))\n",
"checks = [(\"환경 점검 통과\", env_ok == len(libs)), (\"데이터 정합성 전 항목 통과\", all(chk.values())), (\"재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\", a[0] > 0 and b[0] > 0),\n",
" (\"손상도 MAPE 25% 이하(점수 10 이상)\", fin_mape <= 25), (\"면적 기준 모델 대비 MAE 20% 이상 개선\", fin_mae <= 0.8 * base_mae),\n",
" (\"자동 확정 0건(검사 대기·재촬영 분리)\", bool(res[\"처리\"].str.contains(\"검사 대기|재촬영\").all())), (\"오염 의심 사진 전부 재촬영 또는 정밀 판독으로 분리\", bool(res.loc[res[\"오염\"] >= 0.01, \"처리\"].ne(\"검사 대기(간이 승인 후보)\").all())),\n",
" (\"운영 문서 100% 완비\", done == len(docs))]\n",
"print(\"\\n[5] 자체 점검\")\n",
"for n, ok in checks:\n",
" print(f\" [{'PASS' if ok else 'FAIL'}] {n}\")\n",
"print(\"\\n[6] 브리핑 요약\")\n",
"print(f\" - 신규 사진 240장으로 분포 변화를 점검({worst} PSI {drift[worst]:.2f}, {'재학습 검토 수준' if drifted else '경미'})하고 재학습 판단을 자동 수행했다({'챌린저 승격' if promote else '기존 모델 유지'}).\")\n",
"print(f\" - 신규 사진 손상도 MAE {base_mae:.1f}(면적 기준) -> {fin_mae:.1f}, MAPE {fin_mape:.1f}%. 처리 구분: \" + \", \".join(f\"{k} {v}장\" for k, v in vc.items()) + \".\")\n",
"print(\" - 최종 정비 등급은 검사관·정비감독관 결재로 확정하며 AI 판정은 자동 확정되지 않는다. 오염 사진은 재촬영을 요구했다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n",
"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)\n",
"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)\")\n",
"plt.suptitle(\"QLT03 integrated pipeline (synthetic photos)\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[0] 환경 점검: Python 3.11, 필수 모듈 5/5\n",
"[1] 신규 240장 포함 840장 정합성 검증 7/7 통과, 긴급 점검 사진 38장 보존\n",
"[2] 분포 변화 PSI(기존 600 vs 신규 240): gain 0.56, c_mean 0.10, c_std 0.26, rust_frac 0.39, area_frac 0.07, mud 0.79 -> 가장 큰 변화 mud (0.2 이상이면 재학습 검토)\n",
" 검증 80장(신규 분포): 챔피언 MAE 8.79·등급 일치 82.5% vs 챌린저 MAE 6.86·등급 일치 86.2% -> 챌린저 승격(MAE 3% 이상 개선일 때만 승격)\n",
"\n",
"[3] 신규 240장 판정 결과 [교육용가상자료] (상위 6건)\n",
" 사진 판정 유형 손상도 AI 등급 신뢰도 오염 처리\n",
"NEW800000 균열 34.85 1 53.85 0.0 재촬영 요구\n",
"NEW800001 부식 30.58 1 72.79 0.0 검사 대기(정밀 판독)\n",
"NEW800002 결함 없음 1.25 1 87.85 0.0 검사 대기(간이 승인 후보)\n",
"NEW800003 결함 없음 9.30 1 86.00 0.0 검사 대기(간이 승인 후보)\n",
"NEW800004 부식 41.78 2 81.09 0.0 재촬영 요구\n",
"NEW800005 부식 36.30 2 58.39 0.0 재촬영 요구\n",
" 처리 구분: {'검사 대기(간이 승인 후보)': 112, '검사 대기(정밀 판독)': 86, '재촬영 요구': 42} / 자동 확정 0건(모든 판정은 검사관 확인과 정비감독관 결재 필요)\n",
" 간이 승인 후보 112장의 등급 정답 일치율 99.1%, 전체 일치율 90.4%\n",
" 제원 환류: AI 등급과 관리 기록 등급이 2단계 이상 다른 19장은 제원 수정 요청 후보(자동 수정 금지, 담당자 확인)\n",
"\n",
"[4] 운영 문서 12개 항목 중 작성 완료 12개 (100%) / 인터페이스 명세(JSON):\n",
" {\"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\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n",
"\n",
"[5] 자체 점검\n",
" [PASS] 환경 점검 통과\n",
" [PASS] 데이터 정합성 전 항목 통과\n",
" [PASS] 재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\n",
" [PASS] 손상도 MAPE 25% 이하(점수 10 이상)\n",
" [PASS] 면적 기준 모델 대비 MAE 20% 이상 개선\n",
" [PASS] 자동 확정 0건(검사 대기·재촬영 분리)\n",
" [PASS] 오염 의심 사진 전부 재촬영 또는 정밀 판독으로 분리\n",
" [PASS] 운영 문서 100% 완비\n",
"\n",
"[6] 브리핑 요약\n",
" - 신규 사진 240장으로 분포 변화를 점검(mud PSI 0.79, 재학습 검토 수준)하고 재학습 판단을 자동 수행했다(챌린저 승격).\n",
" - 신규 사진 손상도 MAE 13.0(면적 기준) -> 4.5, MAPE 10.6%. 처리 구분: 검사 대기(간이 승인 후보) 112장, 검사 대기(정밀 판독) 86장, 재촬영 요구 42장.\n",
" - 최종 정비 등급은 검사관·정비감독관 결재로 확정하며 AI 판정은 자동 확정되지 않는다. 오염 사진은 재촬영을 요구했다.\n",
"```\n",
"</details>\n"
]
}
],
"metadata": {
"colab": {
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}