Monocular-Depth-Lab / index.html
dobby23's picture
Update index.html
8379480 verified
Raw History Blame Contribute Delete
58.3 kB
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>사진 한 장으로 거리를 잴 수 있을까? — AI 물체 탐지 + 깊이 추정</title>
<!-- 글꼴: 불러오지 못하면 맑은 고딕으로 표시됨 -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
<style>
:root {
--ink: #18202e;
--muted: #5b6474;
--paper: #f3f5f8;
--panel: #ffffff;
--line: #d8dde5;
--accent: #5b1a9e; /* 깊이 색상표의 '먼 쪽' 보라 */
--near: #f89540; /* 깊이 색상표의 '가까운 쪽' 주황 */
--warn: #9a4314;
--ok: #1f6f4a;
--error: #8a1f1f;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: "Pretendard Variable", Pretendard, "Apple SD Gothic Neo", "Malgun Gothic", "맑은 고딕", sans-serif;
line-height: 1.6;
font-variant-numeric: tabular-nums;
}
header, main { max-width: 1120px; margin: 0 auto; padding-left: 20px; padding-right: 20px; }
header { padding-top: 32px; padding-bottom: 8px; }
h1 {
font-size: clamp(24px, 3.4vw, 34px);
line-height: 1.25;
letter-spacing: -0.02em;
font-weight: 750;
margin: 0 0 8px;
}
.lede { color: var(--muted); margin: 0; max-width: 64ch; }
/* 깊이 색상표 범례 — 화면에서 가장 눈에 띄는 요소 */
.legend { margin-top: 18px; max-width: 440px; }
.legend-bar {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #0d0887, #6a00a8, #b12a90, #e16462, #fca636, #f0f921);
}
.legend-labels { display: flex; justify-content: space-between; font-size: 13px; color: var(--muted); margin-top: 4px; }
main {
padding-top: 16px;
padding-bottom: 56px;
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 18px;
min-width: 0;
}
.wide { grid-column: 1 / -1; }
h2 { font-size: 18px; margin: 0; font-weight: 700; letter-spacing: -0.01em; }
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.hint { font-size: 13px; color: var(--muted); margin: 6px 0 0; }
/* ---------- 왼쪽 조작부 ---------- */
.step { margin-bottom: 20px; }
.step-title { display: flex; gap: 8px; align-items: center; font-weight: 650; margin-bottom: 8px; }
.step-num {
display: inline-grid; place-items: center;
width: 22px; height: 22px; border-radius: 50%;
background: var(--ink); color: #fff; font-size: 12px; font-weight: 700;
}
input[type="file"] { width: 100%; font: inherit; font-size: 14px; }
input[type="range"] { width: 100%; accent-color: var(--accent); }
.btn {
appearance: none;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
font: inherit;
font-weight: 650;
padding: 10px 16px;
border-radius: 8px;
cursor: pointer;
}
.btn.block { width: 100%; }
.btn.secondary { background: #fff; color: var(--accent); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
:focus-visible { outline: 3px solid var(--near); outline-offset: 2px; }
.status {
margin-top: 14px;
padding: 10px 12px;
border-radius: 8px;
background: #eef1f5;
font-size: 14px;
}
.status[data-tone="error"] { background: #fbeaea; color: var(--error); }
.status[data-tone="warn"] { background: #fdf2e3; color: var(--warn); }
.status[data-tone="ok"] { background: #e7f3ec; color: var(--ok); }
.seg { display: flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg label { flex: 1; position: relative; cursor: pointer; border-right: 1px solid var(--line); }
.seg label:last-child { border-right: 0; }
.seg input { position: absolute; opacity: 0; pointer-events: none; }
.seg span { display: block; text-align: center; padding: 7px 0; font-size: 14px; }
.seg input:checked + span { background: var(--ink); color: #fff; }
.seg input:focus-visible + span { outline: 3px solid var(--near); outline-offset: -3px; }
.seg input:disabled + span { opacity: 0.4; cursor: not-allowed; }
/* ---------- 결과 화면 ---------- */
#canvas { display: none; width: 100%; height: auto; border-radius: 6px; }
.placeholder {
display: grid; place-items: center;
min-height: 320px;
border: 1px dashed var(--line);
border-radius: 6px;
color: var(--muted);
text-align: center;
padding: 20px;
}
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; padding: 0; list-style: none; }
.chips li { background: #eef1f5; border-radius: 999px; padding: 3px 12px; font-size: 14px; }
.chips li.total { background: var(--ink); color: #fff; }
.table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
th { font-weight: 650; color: var(--muted); font-size: 13px; }
td.num, th.num { text-align: right; }
.objects { min-width: 760px; }
.closeness { display: inline-flex; align-items: center; gap: 8px; }
.closeness i { display: inline-block; width: 14px; height: 14px; border-radius: 3px; border: 1px solid rgba(0,0,0,.15); }
.flag { color: var(--warn); font-size: 13px; }
input.dist {
width: 96px;
font: inherit;
padding: 4px 8px;
border: 1px solid var(--line);
border-radius: 6px;
text-align: right;
}
.empty { color: var(--muted); padding: 12px 0; margin: 0; }
/* ---------- 실측 비교 ---------- */
.exp-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; margin-top: 14px; }
.stat-list { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; margin: 0 0 12px; }
.stat-list dt { color: var(--muted); }
.stat-list dd { margin: 0; font-weight: 650; }
.notes { margin: 0; padding-left: 18px; }
.notes li { margin-bottom: 6px; }
#plot { width: 100%; display: block; border: 1px solid var(--line); border-radius: 6px; }
/* ---------- 처리 시간 ---------- */
.bars { display: grid; gap: 8px; margin-bottom: 10px; }
.bar-row { display: grid; grid-template-columns: 120px minmax(0, 1fr) 90px; gap: 10px; align-items: center; font-size: 14px; }
.bar { height: 10px; background: #e6e9ef; border-radius: 5px; overflow: hidden; }
.bar > i { display: block; height: 100%; background: var(--accent); }
.bar-row .ms { text-align: right; }
.bench-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
@media (max-width: 860px) {
main { grid-template-columns: minmax(0, 1fr); }
.exp-grid { grid-template-columns: minmax(0, 1fr); }
.placeholder { min-height: 200px; }
}
</style>
</head>
<body>
<header>
<h1>사진 한 장으로 거리를 잴 수 있을까?</h1>
<p class="lede">
물체 탐지 모델(YOLOS)로 물체를 찾고, 깊이 추정 모델(Depth Anything V2)로 각 물체가 얼마나 가까운지 계산합니다.
줄자로 잰 실제 거리를 입력하면 AI의 값이 얼마나 맞는지 비교합니다.
</p>
<div class="legend" aria-hidden="true">
<div class="legend-bar"></div>
<div class="legend-labels"><span>멀다</span><span>깊이맵 색상</span><span>가깝다</span></div>
</div>
</header>
<main>
<!-- =====================================================
왼쪽: 조작부
===================================================== -->
<aside class="panel">
<div class="step">
<label class="step-title" for="imageInput"><span class="step-num">1</span>사진 고르기</label>
<input type="file" id="imageInput" accept="image/*" />
<p class="hint">실측 실험용이면 같은 종류의 물체 3~5개를 거리만 다르게 놓고 한 장에 찍으세요.</p>
</div>
<div class="step">
<label class="step-title" for="threshold">
<span class="step-num">2</span>기준 점수 <output id="thresholdValue" for="threshold">0.70</output>
</label>
<input type="range" id="threshold" min="0.1" max="0.95" step="0.05" value="0.70" />
<p class="hint">AI의 확신이 이 점수보다 낮은 물체는 숨깁니다. 다시 분석하지 않고 바로 반영됩니다.</p>
</div>
<button class="btn block" id="runButton" disabled>분석하기</button>
<div class="status" id="status" role="status" aria-live="polite">AI 모델을 불러오는 중입니다.</div>
<div class="step" style="margin-top:20px;margin-bottom:0">
<div class="step-title">화면 보기</div>
<div class="seg" role="radiogroup" aria-label="화면 보기">
<label><input type="radio" name="view" value="photo" checked /><span>사진</span></label>
<label><input type="radio" name="view" value="depth" disabled /><span>깊이맵</span></label>
<label><input type="radio" name="view" value="blend" disabled /><span>겹쳐 보기</span></label>
</div>
</div>
</aside>
<!-- =====================================================
오른쪽: 결과 이미지
===================================================== -->
<section class="panel" aria-label="분석 결과 이미지">
<div class="placeholder" id="placeholder">사진을 고르고 분석하면 여기에 결과가 표시됩니다.</div>
<canvas id="canvas"></canvas>
</section>
<!-- =====================================================
찾은 물체 표
===================================================== -->
<section class="panel wide">
<div class="section-head">
<h2>찾은 물체</h2>
<button class="btn secondary" id="csvButton" disabled>결과를 CSV로 저장</button>
</div>
<ul class="chips" id="summary"></ul>
<div class="table-wrap">
<table class="objects">
<thead>
<tr>
<th>번호</th>
<th>물체</th>
<th class="num">AI 확신도</th>
<th class="num">깊이값(중앙값)</th>
<th>근접도</th>
<th class="num">가까운 순위</th>
<th>참고</th>
<th>실측 거리(cm)</th>
</tr>
</thead>
<tbody id="objectRows"></tbody>
</table>
</div>
<p class="empty" id="tableEmpty">아직 분석한 사진이 없습니다.</p>
<p class="hint">
깊이값은 물체 상자의 가운데 50% 영역에서 구한 중앙값입니다. 상자 가장자리에 섞여 들어오는 배경을 줄이기 위해서입니다.
근접도는 이 사진에서 가장 먼 곳을 0, 가장 가까운 곳을 100으로 바꾼 값입니다.
</p>
</section>
<!-- =====================================================
실측 비교 실험
===================================================== -->
<section class="panel wide">
<div class="section-head">
<h2>실측 비교</h2>
<button class="btn" id="expButton" disabled>실측 비교 계산</button>
</div>
<p class="hint" style="margin-top:0">
표의 마지막 칸에 줄자로 잰 카메라–물체 거리(cm)를 3개 이상 입력하고 계산하세요.
깊이값은 사진마다 기준이 달라지므로, 같은 사진 안의 물체끼리만 비교합니다.
</p>
<div id="expOutput"></div>
</section>
<!-- =====================================================
처리 시간
===================================================== -->
<section class="panel wide">
<div class="section-head">
<h2>처리 시간</h2>
</div>
<div id="timing"><p class="empty">분석을 하면 단계별 처리 시간이 표시됩니다.</p></div>
<p class="hint" id="loadInfo"></p>
<div class="bench-actions">
<button class="btn secondary" id="benchButton" disabled>속도 5회 측정</button>
<span class="hint" style="margin:0">첫 분석에는 초기화 시간이 섞이므로, 반복 측정한 중앙값을 기준으로 삼습니다.</span>
</div>
<div id="benchOutput"></div>
</section>
</main>
<script type="module">
import { pipeline, RawImage }
from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.6";
// =====================================================
// 설정
// =====================================================
const DETECT_MODEL = "Xenova/yolos-tiny";
// 첫 번째 모델을 못 불러오면 두 번째로 다시 시도
const DEPTH_MODELS = [
"onnx-community/depth-anything-v2-small",
"onnx-community/depth-anything-v2-small-ONNX",
"Xenova/depth-anything-small-hf"
];
const MAX_SIDE = 960; // 분석용 사진의 긴 변 최대 크기(px). 휴대폰 원본 사진은 너무 커서 줄임
const BASE_THRESHOLD = 0.1; // 탐지는 낮은 기준으로 한 번만 하고, 슬라이더로 걸러서 보여줌
const CENTER_RATIO = 0.5; // 깊이값을 구할 때 쓰는 상자 가운데 영역 비율
const SPREAD_WARN = 0.2; // 상자 안 깊이 편차가 이보다 크면 '배경 섞임' 경고
const SMALL_BOX_WARN = 0.01; // 사진 면적의 1%보다 작은 물체는 '작은 물체' 경고
const MAX_SAMPLES = 40000; // 상자 하나에서 읽을 깊이 픽셀 최대 개수
const BENCH_RUNS = 5;
// =====================================================
// 물체 이름 한글 표시 (COCO 80종 전체)
// =====================================================
const LABEL_KO = {
"person": "사람", "bicycle": "자전거", "car": "자동차", "motorcycle": "오토바이",
"airplane": "비행기", "bus": "버스", "train": "기차", "truck": "트럭", "boat": "보트",
"traffic light": "신호등", "fire hydrant": "소화전", "stop sign": "정지 표지판",
"parking meter": "주차 요금기", "bench": "벤치", "bird": "새", "cat": "고양이",
"dog": "강아지", "horse": "말", "sheep": "양", "cow": "소", "elephant": "코끼리",
"bear": "곰", "zebra": "얼룩말", "giraffe": "기린", "backpack": "배낭",
"umbrella": "우산", "handbag": "핸드백", "tie": "넥타이", "suitcase": "여행가방",
"frisbee": "원반", "skis": "스키", "snowboard": "스노보드", "sports ball": "공",
"kite": "연", "baseball bat": "야구 배트", "baseball glove": "야구 글러브",
"skateboard": "스케이트보드", "surfboard": "서핑보드", "tennis racket": "테니스 라켓",
"bottle": "병", "wine glass": "와인잔", "cup": "컵", "fork": "포크", "knife": "칼",
"spoon": "숟가락", "bowl": "그릇", "banana": "바나나", "apple": "사과",
"sandwich": "샌드위치", "orange": "오렌지", "broccoli": "브로콜리", "carrot": "당근",
"hot dog": "핫도그", "pizza": "피자", "donut": "도넛", "cake": "케이크",
"chair": "의자", "couch": "소파", "potted plant": "화분", "bed": "침대",
"dining table": "식탁", "toilet": "변기", "tv": "TV", "laptop": "노트북",
"mouse": "마우스", "remote": "리모컨", "keyboard": "키보드", "cell phone": "휴대폰",
"microwave": "전자레인지", "oven": "오븐", "toaster": "토스터", "sink": "싱크대",
"refrigerator": "냉장고", "book": "책", "clock": "시계", "vase": "꽃병",
"scissors": "가위", "teddy bear": "곰인형", "hair drier": "헤어드라이어",
"toothbrush": "칫솔"
};
// =====================================================
// 계산 함수 (화면과 무관한 순수 계산)
// PURE-START
// =====================================================
function labelKo(label) {
return LABEL_KO[label] ?? label;
}
function labelFull(label) {
return LABEL_KO[label] ? `${LABEL_KO[label]} (${label})` : label;
}
// 정렬된 배열에서 p 위치의 값 (0.5 = 중앙값)
function quantileSorted(sorted, p) {
if (sorted.length === 0) return NaN;
const pos = (sorted.length - 1) * p;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
function median(values) {
return quantileSorted(Float64Array.from(values).sort(), 0.5);
}
function clamp(v, lo, hi) {
return Math.min(hi, Math.max(lo, v));
}
// 깊이 배열의 최솟값·최댓값을 함께 담아 둠
function makeDepth(data, w, h) {
if (!data || data.length !== w * h) {
throw new Error(`깊이 데이터 크기가 맞지 않습니다 (${data?.length} ≠ ${w}×${h}).`);
}
let min = Infinity;
let max = -Infinity;
for (let i = 0; i < data.length; i++) {
const v = data[i];
if (v < min) min = v;
if (v > max) max = v;
}
return { data, w, h, min, max };
}
// 탐지 상자(사진 좌표)를 깊이맵 좌표로 옮겨, 가운데 영역의 깊이값 통계를 구함
// Depth Anything의 값은 '클수록 가깝다'
function boxDepthStats(depth, box, imgW, imgH) {
const sx = depth.w / imgW;
const sy = depth.h / imgH;
const bw = box.xmax - box.xmin;
const bh = box.ymax - box.ymin;
const margin = (1 - CENTER_RATIO) / 2;
let x0 = Math.floor((box.xmin + bw * margin) * sx);
let x1 = Math.ceil((box.xmax - bw * margin) * sx);
let y0 = Math.floor((box.ymin + bh * margin) * sy);
let y1 = Math.ceil((box.ymax - bh * margin) * sy);
x0 = clamp(x0, 0, depth.w - 1);
x1 = clamp(x1, x0 + 1, depth.w);
y0 = clamp(y0, 0, depth.h - 1);
y1 = clamp(y1, y0 + 1, depth.h);
const area = (x1 - x0) * (y1 - y0);
const step = Math.max(1, Math.ceil(Math.sqrt(area / MAX_SAMPLES)));
const values = [];
for (let y = y0; y < y1; y += step) {
for (let x = x0; x < x1; x += step) {
values.push(depth.data[y * depth.w + x]);
}
}
const sorted = Float64Array.from(values).sort();
const med = quantileSorted(sorted, 0.5);
const q1 = quantileSorted(sorted, 0.25);
const q3 = quantileSorted(sorted, 0.75);
const range = (depth.max - depth.min) || 1;
return {
median: med,
q1,
q3,
samples: sorted.length,
closeness: ((med - depth.min) / range) * 100,
spread: (q3 - q1) / range,
areaRatio: (bw * bh) / (imgW * imgH)
};
}
// 실패 조건 힌트
function warningsFor(stats) {
const list = [];
if (!stats) return list;
if (stats.spread > SPREAD_WARN) list.push("상자 안 깊이 편차 큼(배경 섞임·기울어짐)");
if (stats.areaRatio < SMALL_BOX_WARN) list.push("작은 물체(픽셀 부족)");
return list;
}
// 가까운 순위: 깊이값이 클수록 1위
function closenessRanks(items) {
const ranked = items
.filter(d => d.stats)
.slice()
.sort((a, b) => b.stats.median - a.stats.median);
const ranks = new Map();
ranked.forEach((d, i) => ranks.set(d.id, i + 1));
return ranks;
}
// 두 물체씩 짝지어, '실제로 더 가까운 쪽의 깊이값이 더 큰가'를 셈
function pairwiseConcordance(points) {
let concordant = 0;
let total = 0;
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const dd = points[i].d - points[j].d;
if (dd === 0) continue;
total++;
const dv = points[i].v - points[j].v;
if (dd * dv < 0) concordant++;
}
}
return { concordant, total };
}
// 피어슨 상관계수 (-1 ~ 1)
function pearson(xs, ys) {
const n = xs.length;
if (n < 2) return null;
const mx = xs.reduce((s, v) => s + v, 0) / n;
const my = ys.reduce((s, v) => s + v, 0) / n;
let sxy = 0, sxx = 0, syy = 0;
for (let i = 0; i < n; i++) {
sxy += (xs[i] - mx) * (ys[i] - my);
sxx += (xs[i] - mx) ** 2;
syy += (ys[i] - my) ** 2;
}
if (sxx === 0 || syy === 0) return null;
return sxy / Math.sqrt(sxx * syy);
}
// 최소제곱 직선 y = a·x + b
function linearFit(xs, ys) {
const n = xs.length;
if (n < 2) return null;
const mx = xs.reduce((s, v) => s + v, 0) / n;
const my = ys.reduce((s, v) => s + v, 0) / n;
let sxy = 0, sxx = 0;
for (let i = 0; i < n; i++) {
sxy += (xs[i] - mx) * (ys[i] - my);
sxx += (xs[i] - mx) ** 2;
}
if (sxx === 0) return null;
const a = sxy / sxx;
return { a, b: my - a * mx };
}
// 깊이값 = a·(100 / 거리cm) + b → 거리cm = 100·a / (깊이값 − b)
function inverseX(distanceCm) {
return 100 / distanceCm; // 1 / 거리(m)
}
function predictDistanceCm(value, fit) {
if (!fit || fit.a === 0) return null;
const x = (value - fit.b) / fit.a;
if (!(x > 0) || !Number.isFinite(x)) return null;
return 100 / x;
}
// 하나 빼고 맞히기: 나머지로 보정식을 만들고, 뺀 물체의 거리를 추정
function leaveOneOut(points) {
return points.map((p, i) => {
const others = points.filter((_, j) => j !== i);
const fit = linearFit(others.map(o => inverseX(o.d)), others.map(o => o.v));
const pred = predictDistanceCm(p.v, fit);
const err = pred === null ? null : (Math.abs(pred - p.d) / p.d) * 100;
return { id: p.id, d: p.d, v: p.v, pred, err };
});
}
function formatValue(v) {
if (v === null || v === undefined || Number.isNaN(v)) return "—";
return Math.abs(v) >= 100 ? v.toFixed(1) : v.toFixed(3);
}
function csvCell(v) {
const s = String(v ?? "");
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
// 깊이맵 색상표 (plasma 계열): 0 = 멀다(남보라), 1 = 가깝다(노랑)
function buildColorLUT() {
const stops = [
[0.00, [13, 8, 135]],
[0.20, [106, 0, 168]],
[0.40, [177, 42, 144]],
[0.60, [225, 100, 98]],
[0.80, [252, 166, 54]],
[1.00, [240, 249, 33]]
];
const lut = new Uint8ClampedArray(256 * 3);
for (let i = 0; i < 256; i++) {
const t = i / 255;
let k = 0;
while (k < stops.length - 2 && t > stops[k + 1][0]) k++;
const [t0, c0] = stops[k];
const [t1, c1] = stops[k + 1];
const f = (t - t0) / (t1 - t0);
for (let c = 0; c < 3; c++) lut[i * 3 + c] = c0[c] + (c1[c] - c0[c]) * f;
}
return lut;
}
// PURE-END
// =====================================================
const LUT = buildColorLUT();
function colorFor(closeness) {
const i = clamp(Math.round((closeness / 100) * 255), 0, 255) * 3;
return [LUT[i], LUT[i + 1], LUT[i + 2]];
}
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// =====================================================
// HTML 요소
// =====================================================
const $ = id => document.getElementById(id);
const els = {
file: $("imageInput"),
threshold: $("threshold"),
thresholdValue: $("thresholdValue"),
run: $("runButton"),
status: $("status"),
canvas: $("canvas"),
placeholder: $("placeholder"),
summary: $("summary"),
rows: $("objectRows"),
tableEmpty: $("tableEmpty"),
csv: $("csvButton"),
exp: $("expButton"),
expOut: $("expOutput"),
timing: $("timing"),
loadInfo: $("loadInfo"),
bench: $("benchButton"),
benchOut: $("benchOutput"),
viewRadios: document.querySelectorAll('input[name="view"]')
};
const ctx = els.canvas.getContext("2d");
// =====================================================
// 상태
// =====================================================
let detector = null;
let depthEstimator = null;
let depthModelName = null;
const loadTimes = { detect: null, depth: null };
const progress = { detect: new Map(), depth: new Map() };
const loadState = { detect: "loading", depth: "loading" };
const state = {
busy: false,
fileName: "",
W: 0,
H: 0,
srcCanvas: null, // 분석용 크기로 줄인 원본 사진
raw: null, // 모델에 넣는 RawImage
detections: [], // 기준 0.1로 찾은 전체 결과 (확신도 높은 순, id 고정)
depth: null, // { data, w, h, min, max }
depthCanvas: null, // 색을 입힌 깊이맵
distances: new Map(),// id → 실측 거리(cm)
loo: new Map(), // id → { pred, err }
timing: null,
bench: null,
plot: null // 그래프를 다시 그릴 때 쓰는 값
};
// =====================================================
// 공통 화면 갱신
// =====================================================
function setStatus(text, tone = "") {
els.status.textContent = text;
els.status.dataset.tone = tone;
}
function updateButtons() {
const hasImage = state.raw !== null;
els.run.disabled = state.busy || detector === null;
els.csv.disabled = state.busy || !hasImage;
els.bench.disabled = state.busy || !hasImage;
els.exp.disabled = state.busy || !hasImage || state.depth === null;
els.threshold.disabled = state.busy;
for (const radio of els.viewRadios) {
if (radio.value !== "photo") radio.disabled = state.depth === null;
}
}
function setBusy(flag) {
state.busy = flag;
updateButtons();
}
function currentThreshold() {
return Number(els.threshold.value);
}
function currentView() {
for (const radio of els.viewRadios) if (radio.checked) return radio.value;
return "photo";
}
function visibleDetections() {
const thr = currentThreshold();
return state.detections.filter(d => d.score >= thr);
}
// =====================================================
// AI 모델 불러오기 (두 모델을 동시에 내려받음)
// =====================================================
function percentOf(map) {
let loaded = 0, total = 0;
for (const v of map.values()) { loaded += v.loaded; total += v.total; }
return total > 0 ? Math.round((loaded / total) * 100) : 0;
}
function progressHandler(key) {
return p => {
if (p.status === "progress" && p.file) {
progress[key].set(p.file, { loaded: p.loaded ?? 0, total: p.total ?? 0 });
renderLoadProgress();
}
};
}
function stateText(key) {
if (loadState[key] === "ready") return "준비됨";
if (loadState[key] === "failed") return "실패";
return `${percentOf(progress[key])}%`;
}
function renderLoadProgress() {
setStatus(`AI 모델을 내려받는 중입니다. 물체 탐지 ${stateText("detect")}, 깊이 추정 ${stateText("depth")}. 처음 한 번만 오래 걸립니다.`);
}
async function loadDetector() {
const start = performance.now();
detector = await pipeline("object-detection", DETECT_MODEL, {
progress_callback: progressHandler("detect")
});
loadTimes.detect = performance.now() - start;
loadState.detect = "ready";
renderLoadProgress();
}
async function loadDepthEstimator() {
let lastError = null;
for (const name of DEPTH_MODELS) {
try {
progress.depth.clear();
const start = performance.now();
depthEstimator = await pipeline("depth-estimation", name, {
progress_callback: progressHandler("depth")
});
loadTimes.depth = performance.now() - start;
depthModelName = name;
loadState.depth = "ready";
renderLoadProgress();
return;
} catch (error) {
console.warn(`깊이 모델 ${name} 불러오기 실패`, error);
lastError = error;
}
}
loadState.depth = "failed";
throw lastError;
}
async function loadModels() {
renderLoadProgress();
const [det, dep] = await Promise.allSettled([loadDetector(), loadDepthEstimator()]);
if (det.status === "rejected") {
loadState.detect = "failed";
console.error(det.reason);
setStatus(`물체 탐지 모델을 불러오지 못했습니다: ${det.reason?.message ?? det.reason}. 인터넷 연결을 확인하고 새로고침하세요.`, "error");
} else if (dep.status === "rejected") {
console.error(dep.reason);
setStatus("깊이 추정 모델을 불러오지 못해 물체 탐지만 합니다. 인터넷 연결을 확인하고 새로고침하면 다시 시도합니다.", "warn");
} else {
setStatus("준비됐습니다. 사진을 고르고 분석하세요.", "ok");
}
renderLoadInfo();
updateButtons();
}
function renderLoadInfo() {
const parts = [];
if (loadTimes.detect !== null) parts.push(`물체 탐지 ${(loadTimes.detect / 1000).toFixed(1)}초`);
if (loadTimes.depth !== null) parts.push(`깊이 추정 ${(loadTimes.depth / 1000).toFixed(1)}초`);
let text = parts.length ? `모델 불러오기 시간: ${parts.join(", ")}.` : "";
if (depthModelName) text += ` 사용한 깊이 모델: ${depthModelName}.`;
els.loadInfo.textContent = text;
}
// =====================================================
// 사진 준비: 분석용 크기로 줄이고 RawImage로 바꿈
// =====================================================
function loadImageElement(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("사진을 열 수 없습니다. 다른 형식(JPG, PNG)으로 시도하세요.")); };
img.src = url;
});
}
async function prepareImage(file) {
const img = await loadImageElement(file);
const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth, img.naturalHeight));
const W = Math.max(1, Math.round(img.naturalWidth * scale));
const H = Math.max(1, Math.round(img.naturalHeight * scale));
const src = document.createElement("canvas");
src.width = W;
src.height = H;
const sctx = src.getContext("2d", { willReadFrequently: true });
sctx.drawImage(img, 0, 0, W, H);
const pixels = sctx.getImageData(0, 0, W, H).data;
const raw = new RawImage(pixels, W, H, 4).rgb();
return { W, H, src, raw };
}
// =====================================================
// 깊이 결과 읽기
// =====================================================
function readDepth(output) {
const result = Array.isArray(output) ? output[0] : output;
let tensor = result?.predicted_depth;
if (tensor?.dims && tensor?.data) {
if (tensor.type && tensor.type !== "float32" && typeof tensor.to === "function") {
tensor = tensor.to("float32");
}
const dims = tensor.dims;
return makeDepth(tensor.data, dims[dims.length - 1], dims[dims.length - 2]);
}
// 예비 경로: 0~255로 바뀐 깊이 이미지
if (result?.depth?.data) {
return makeDepth(result.depth.data, result.depth.width, result.depth.height);
}
throw new Error("깊이 결과 형식을 읽을 수 없습니다.");
}
function buildDepthCanvas(depth) {
const c = document.createElement("canvas");
c.width = depth.w;
c.height = depth.h;
const g = c.getContext("2d");
const img = g.createImageData(depth.w, depth.h);
const range = (depth.max - depth.min) || 1;
for (let i = 0; i < depth.data.length; i++) {
const k = clamp(Math.round(((depth.data[i] - depth.min) / range) * 255), 0, 255) * 3;
img.data[i * 4] = LUT[k];
img.data[i * 4 + 1] = LUT[k + 1];
img.data[i * 4 + 2] = LUT[k + 2];
img.data[i * 4 + 3] = 255;
}
g.putImageData(img, 0, 0);
return c;
}
// =====================================================
// 분석 (탐지 → 깊이 추정 → 물체별 깊이 계산)
// 처리 시간을 단계별로 재기 위해 두 모델을 차례로 실행함
// =====================================================
async function analyze() {
if (detector === null) {
setStatus("물체 탐지 모델이 아직 준비되지 않았습니다.", "warn");
return;
}
const file = els.file.files[0];
if (!file) {
setStatus("사진을 먼저 고르세요.", "warn");
return;
}
setBusy(true);
try {
const t0 = performance.now();
setStatus("사진을 준비하는 중입니다.");
const prepared = await prepareImage(file);
const t1 = performance.now();
setStatus("물체를 찾는 중입니다.");
const found = await detector(prepared.raw, { threshold: BASE_THRESHOLD });
const t2 = performance.now();
let depth = null;
let t3 = t2;
if (depthEstimator !== null) {
setStatus("깊이를 추정하는 중입니다.");
const out = await depthEstimator(prepared.raw);
depth = readDepth(out);
t3 = performance.now();
}
// ---------- 결과 저장 ----------
state.fileName = file.name;
state.W = prepared.W;
state.H = prepared.H;
state.srcCanvas = prepared.src;
state.raw = prepared.raw;
state.depth = depth;
state.depthCanvas = depth ? buildDepthCanvas(depth) : null;
state.distances = new Map();
state.loo = new Map();
state.bench = null;
els.benchOut.innerHTML = "";
state.detections = found
.slice()
.sort((a, b) => b.score - a.score)
.map((item, i) => ({
id: i + 1,
label: item.label,
score: item.score,
box: item.box,
stats: depth ? boxDepthStats(depth, item.box, prepared.W, prepared.H) : null
}));
if (!depth && currentView() !== "photo") {
els.viewRadios[0].checked = true;
}
renderAll();
resetExperiment();
const t4 = performance.now();
state.timing = {
prep: t1 - t0,
detect: t2 - t1,
depth: depth ? t3 - t2 : null,
post: t4 - t3
};
renderTiming();
const shown = visibleDetections().length;
setStatus(
shown > 0
? `분석을 마쳤습니다. 기준 점수 이상인 물체 ${shown}개를 찾았습니다.`
: "분석을 마쳤지만 기준 점수 이상인 물체가 없습니다. 기준 점수를 낮춰 보세요.",
shown > 0 ? "ok" : "warn"
);
} catch (error) {
console.error(error);
setStatus(`분석에 실패했습니다: ${error.message}`, "error");
} finally {
setBusy(false);
}
}
// =====================================================
// 그리기
// =====================================================
function renderAll() {
const visible = visibleDetections();
const ranks = closenessRanks(visible);
drawCanvas(visible, ranks);
renderSummary(visible);
renderTable(visible, ranks);
}
function drawCanvas(visible, ranks) {
if (!state.srcCanvas) return;
const { W, H } = state;
const view = currentView();
els.canvas.width = W;
els.canvas.height = H;
ctx.clearRect(0, 0, W, H);
if (view === "depth" && state.depthCanvas) {
ctx.drawImage(state.depthCanvas, 0, 0, W, H);
} else {
ctx.drawImage(state.srcCanvas, 0, 0);
if (view === "blend" && state.depthCanvas) {
ctx.globalAlpha = 0.6;
ctx.drawImage(state.depthCanvas, 0, 0, W, H);
ctx.globalAlpha = 1;
}
}
// 캔버스가 화면에 몇 배로 줄어 보이는지 계산해서,
// 화면에서 볼 때 글자가 최소 12px, 선이 최소 2px가 되도록 맞춤
els.placeholder.style.display = "none";
els.canvas.style.display = "block";
const shownWidth = els.canvas.getBoundingClientRect().width || W;
const scale = shownWidth / W; // 1보다 작으면 축소되어 보이는 중
const lineWidth = Math.max(W / 320, 2 / scale);
const fontSize = Math.round(Math.max(W / 48, 12 / scale));
const compact = shownWidth < 520; // 좁은 화면은 이름표를 짧게
ctx.font = `600 ${fontSize}px "Pretendard Variable", "Malgun Gothic", sans-serif`;
ctx.textBaseline = "middle";
// 먼 물체부터 그려서 가까운 물체의 이름표가 위에 오게 함
const order = visible.slice().sort((a, b) => (b.stats ? ranks.get(b.id) : 0) - (a.stats ? ranks.get(a.id) : 0));
for (const d of order) {
const x = clamp(d.box.xmin, 0, W);
const y = clamp(d.box.ymin, 0, H);
const w = clamp(d.box.xmax, 0, W) - x;
const h = clamp(d.box.ymax, 0, H) - y;
const rgb = d.stats ? colorFor(d.stats.closeness) : [255, 51, 102];
const color = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
// 흰 테두리를 먼저 그려 깊이맵 위에서도 상자가 보이게 함
ctx.lineWidth = lineWidth + 3;
ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.strokeRect(x, y, w, h);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.strokeRect(x, y, w, h);
// 이름표
let text = compact
? `#${d.id}`
: `#${d.id} ${labelKo(d.label)} ${(d.score * 100).toFixed(0)}%`;
if (d.stats) text += ` 근접 ${d.stats.closeness.toFixed(0)}`;
const padX = Math.round(fontSize * 0.45);
const tagH = Math.round(fontSize * 1.5);
const tagW = ctx.measureText(text).width + padX * 2;
const tagX = clamp(x, 0, Math.max(0, W - tagW));
const tagY = y - tagH >= 0 ? y - tagH : y; // 위에 자리가 없으면 상자 안쪽에
ctx.fillStyle = color;
ctx.fillRect(tagX, tagY, tagW, tagH);
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
ctx.fillStyle = luminance > 0.6 ? "#18202e" : "#ffffff";
ctx.fillText(text, tagX + padX, tagY + tagH / 2);
}
}
function renderSummary(visible) {
if (!state.raw) { els.summary.innerHTML = ""; return; }
const counts = new Map();
for (const d of visible) counts.set(d.label, (counts.get(d.label) ?? 0) + 1);
const items = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.map(([label, n]) => `<li>${esc(labelKo(label))} ${n}개</li>`);
els.summary.innerHTML = `<li class="total">전체 ${visible.length}개</li>${items.join("")}`;
}
function renderTable(visible, ranks) {
els.rows.innerHTML = "";
if (!state.raw) {
els.tableEmpty.textContent = "아직 분석한 사진이 없습니다.";
els.tableEmpty.style.display = "block";
return;
}
if (visible.length === 0) {
els.tableEmpty.textContent = "기준 점수 이상인 물체가 없습니다. 기준 점수를 낮춰 보세요.";
els.tableEmpty.style.display = "block";
return;
}
els.tableEmpty.style.display = "none";
const fragment = document.createDocumentFragment();
for (const d of visible) {
const tr = document.createElement("tr");
const s = d.stats;
const rgb = s ? colorFor(s.closeness) : null;
const warns = warningsFor(s);
tr.innerHTML = `
<td>#${d.id}</td>
<td>${esc(labelFull(d.label))}</td>
<td class="num">${(d.score * 100).toFixed(1)}%</td>
<td class="num">${s ? formatValue(s.median) : "—"}</td>
<td>${s ? `<span class="closeness"><i style="background:rgb(${rgb.join(",")})"></i>${s.closeness.toFixed(0)}</span>` : "—"}</td>
<td class="num">${s ? ranks.get(d.id) : "—"}</td>
<td class="flag">${warns.length ? esc(warns.join(", ")) : ""}</td>
<td></td>`;
const input = document.createElement("input");
input.type = "number";
input.className = "dist";
input.min = "1";
input.step = "any";
input.inputMode = "decimal";
input.placeholder = "cm";
input.setAttribute("aria-label", `#${d.id} ${labelKo(d.label)} 실측 거리(cm)`);
input.dataset.id = String(d.id);
input.disabled = !s;
if (state.distances.has(d.id)) input.value = String(state.distances.get(d.id));
input.addEventListener("input", () => {
const v = Number(input.value);
if (input.value !== "" && Number.isFinite(v) && v > 0) state.distances.set(d.id, v);
else state.distances.delete(d.id);
});
tr.lastElementChild.appendChild(input);
fragment.appendChild(tr);
}
els.rows.appendChild(fragment);
}
// =====================================================
// 실측 비교 실험
// =====================================================
function resetExperiment(message = "") {
state.loo = new Map();
state.plot = null;
els.expOut.innerHTML = message ? `<p class="empty">${esc(message)}</p>` : "";
}
function runExperiment() {
if (!state.depth) {
resetExperiment("깊이 추정 결과가 없어 비교할 수 없습니다.");
return;
}
const points = visibleDetections()
.filter(d => d.stats && state.distances.has(d.id))
.map(d => ({ id: d.id, label: d.label, d: state.distances.get(d.id), v: d.stats.median }));
if (points.length < 3) {
resetExperiment(`실측 거리를 3개 이상 입력하세요. 지금 ${points.length}개 입력됐습니다.`);
return;
}
const distinct = new Set(points.map(p => p.d));
if (distinct.size < 2) {
resetExperiment("실측 거리가 모두 같습니다. 서로 다른 거리에 놓인 물체를 입력하세요.");
return;
}
const { concordant, total } = pairwiseConcordance(points);
const vs = points.map(p => p.v);
const rDist = pearson(vs, points.map(p => p.d));
const rInv = pearson(vs, points.map(p => inverseX(p.d)));
const fit = linearFit(points.map(p => inverseX(p.d)), vs);
const loo = points.length >= 4 ? leaveOneOut(points) : null;
state.loo = new Map((loo ?? []).map(r => [r.id, r]));
// ---------- 해석 문장 ----------
const notes = [];
const orderPct = total > 0 ? (concordant / total) * 100 : 0;
notes.push(concordant === total
? "가까운 순서는 모든 쌍에서 맞았습니다."
: `가까운 순서가 뒤바뀐 쌍이 ${total - concordant}개 있습니다. 표의 '참고' 칸과 사진의 조명·재질을 확인하세요.`);
if (rDist !== null && rInv !== null) {
notes.push(Math.abs(rInv) > Math.abs(rDist)
? "깊이값은 거리 자체보다 거리의 역수(1/거리)와 더 직선 관계입니다. 가까운 곳에서는 값 차이가 크게 벌어지고 먼 곳에서는 좁아집니다."
: "이 측정에서는 깊이값이 거리의 역수보다 거리 자체와 더 직선 관계로 나왔습니다. 측정 개수를 늘려 다시 확인해 볼 만합니다.");
}
notes.push("깊이값은 사진마다 배율과 0점이 달라지는 상대값입니다. cm로 바꾸려면 같은 사진 안의 기준 물체로 보정해야 하고, 다른 사진끼리는 비교할 수 없습니다.");
let looHtml = "";
if (loo) {
const errs = loo.map(r => r.err).filter(e => e !== null);
const failed = loo.length - errs.length;
const mean = errs.length ? errs.reduce((s, e) => s + e, 0) / errs.length : null;
const max = errs.length ? Math.max(...errs) : null;
notes.push(
`하나를 빼고 나머지로 보정식을 만든 뒤 빠진 물체의 거리를 맞혀 본 결과, ` +
(mean !== null ? `평균 오차 ${mean.toFixed(1)}%, 최대 오차 ${max.toFixed(1)}%입니다.` : "모두 추정에 실패했습니다.") +
(failed ? ` ${failed}개는 보정식으로 거리가 계산되지 않았습니다(추정 불가).` : "")
);
looHtml = `
<div class="table-wrap">
<table>
<thead><tr><th>번호</th><th>물체</th><th class="num">깊이값</th><th class="num">실측(cm)</th><th class="num">추정(cm)</th><th class="num">오차</th></tr></thead>
<tbody>
${loo.map(r => {
const p = points.find(q => q.id === r.id);
return `<tr>
<td>#${r.id}</td>
<td>${esc(labelKo(p.label))}</td>
<td class="num">${formatValue(r.v)}</td>
<td class="num">${r.d}</td>
<td class="num">${r.pred === null ? "추정 불가" : r.pred.toFixed(1)}</td>
<td class="num">${r.err === null ? "—" : r.err.toFixed(1) + "%"}</td>
</tr>`;
}).join("")}
</tbody>
</table>
</div>`;
} else {
notes.push("실측 거리를 4개 이상 입력하면 '하나 빼고 맞히기'로 거리 추정 오차도 계산합니다.");
}
els.expOut.innerHTML = `
<div class="exp-grid">
<div>
<dl class="stat-list">
<dt>비교한 물체</dt><dd>${points.length}개</dd>
<dt>순서 일치</dt><dd>${concordant} / ${total}쌍 (${orderPct.toFixed(0)}%)</dd>
<dt>상관계수: 거리</dt><dd>${rDist === null ? "—" : rDist.toFixed(3)}</dd>
<dt>상관계수: 1/거리</dt><dd>${rInv === null ? "—" : rInv.toFixed(3)}</dd>
</dl>
<ul class="notes">${notes.map(n => `<li>${esc(n)}</li>`).join("")}</ul>
</div>
<div>
<canvas id="plot" role="img" aria-label="1/실측 거리와 깊이값의 산점도"></canvas>
<p class="hint">점: 물체 하나. 선: 모든 점으로 만든 보정식(깊이값 = a × 1/거리 + b).</p>
</div>
</div>
${looHtml}`;
state.plot = { points, fit };
drawPlot($("plot"), points, fit);
}
function drawPlot(canvas, points, fit) {
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth || 520;
const cssH = Math.round(cssW * 0.62);
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
canvas.style.height = `${cssH}px`;
const g = canvas.getContext("2d");
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, cssW, cssH);
const pad = { l: 70, r: 24, t: 16, b: 46 };
const xs = points.map(p => inverseX(p.d));
const ys = points.map(p => p.v);
let xMin = Math.min(...xs), xMax = Math.max(...xs);
let yMin = Math.min(...ys), yMax = Math.max(...ys);
const xPad = (xMax - xMin || 1) * 0.12;
const yPad = (yMax - yMin || 1) * 0.15;
xMin -= xPad; xMax += xPad; yMin -= yPad; yMax += yPad;
const px = x => pad.l + ((x - xMin) / (xMax - xMin)) * (cssW - pad.l - pad.r);
const py = y => cssH - pad.b - ((y - yMin) / (yMax - yMin)) * (cssH - pad.t - pad.b);
// 축과 눈금
g.strokeStyle = "#d8dde5";
g.fillStyle = "#5b6474";
g.lineWidth = 1;
g.font = '12px "Pretendard Variable", "Malgun Gothic", sans-serif';
for (let i = 0; i <= 4; i++) {
const xv = xMin + ((xMax - xMin) * i) / 4;
const yv = yMin + ((yMax - yMin) * i) / 4;
g.beginPath(); g.moveTo(px(xv), pad.t); g.lineTo(px(xv), cssH - pad.b); g.stroke();
g.beginPath(); g.moveTo(pad.l, py(yv)); g.lineTo(cssW - pad.r, py(yv)); g.stroke();
g.textAlign = "center"; g.textBaseline = "top";
g.fillText(xv.toFixed(2), px(xv), cssH - pad.b + 6);
g.textAlign = "right"; g.textBaseline = "middle";
g.fillText(formatValue(yv), pad.l - 6, py(yv));
}
g.textAlign = "center"; g.textBaseline = "bottom";
g.fillText("1 / 실측 거리 (1/m)", pad.l + (cssW - pad.l - pad.r) / 2, cssH - 4);
g.save();
g.translate(12, pad.t + (cssH - pad.t - pad.b) / 2);
g.rotate(-Math.PI / 2);
g.textBaseline = "middle";
g.fillText("깊이값", 0, 0);
g.restore();
// 보정식 직선
if (fit) {
g.strokeStyle = "#f89540";
g.lineWidth = 2;
g.beginPath();
g.moveTo(px(xMin), py(fit.a * xMin + fit.b));
g.lineTo(px(xMax), py(fit.a * xMax + fit.b));
g.stroke();
}
// 점
g.fillStyle = "#5b1a9e";
g.textAlign = "left";
g.textBaseline = "bottom";
for (const p of points) {
const x = px(inverseX(p.d));
const y = py(p.v);
g.beginPath(); g.arc(x, y, 5, 0, Math.PI * 2); g.fill();
g.fillText(`#${p.id}`, x + 7, y - 3);
}
}
// =====================================================
// 처리 시간
// =====================================================
function renderTiming() {
const t = state.timing;
if (!t) return;
const rows = [
["사진 준비", t.prep],
["물체 탐지", t.detect],
["깊이 추정", t.depth],
["후처리·그리기", t.post]
];
const max = Math.max(...rows.map(r => r[1] ?? 0)) || 1;
const total = rows.reduce((s, r) => s + (r[1] ?? 0), 0);
els.timing.innerHTML = `
<div class="bars">
${rows.map(([name, ms]) => `
<div class="bar-row">
<span>${name}</span>
<div class="bar"><i style="width:${ms === null ? 0 : (ms / max) * 100}%"></i></div>
<span class="ms">${ms === null ? "건너뜀" : `${Math.round(ms)} ms`}</span>
</div>`).join("")}
</div>
<p class="hint" style="margin:0">합계 ${Math.round(total)} ms. 분석용 사진 크기 ${state.W}×${state.H}px.</p>`;
}
async function runBenchmark() {
if (!state.raw) return;
setBusy(true);
const det = [];
const dep = [];
try {
for (let i = 0; i < BENCH_RUNS; i++) {
setStatus(`속도를 측정하는 중입니다 (${i + 1}/${BENCH_RUNS}).`);
let start = performance.now();
await detector(state.raw, { threshold: BASE_THRESHOLD });
det.push(performance.now() - start);
if (depthEstimator) {
start = performance.now();
await depthEstimator(state.raw);
dep.push(performance.now() - start);
}
}
state.bench = { det, dep };
const row = (name, arr) => arr.length
? `<tr><td>${name}</td><td class="num">${Math.round(median(arr))} ms</td><td class="num">${Math.round(Math.min(...arr))} ms</td><td class="num">${Math.round(Math.max(...arr))} ms</td></tr>`
: "";
const perFrame = median(det) + (dep.length ? median(dep) : 0);
els.benchOut.innerHTML = `
<div class="table-wrap" style="margin-top:12px">
<table>
<thead><tr><th>단계</th><th class="num">중앙값</th><th class="num">최소</th><th class="num">최대</th></tr></thead>
<tbody>${row("물체 탐지", det)}${row("깊이 추정", dep)}</tbody>
</table>
</div>
<p class="hint">${perFrame >= 1
? `한 장 처리에 약 ${Math.round(perFrame)} ms가 걸립니다. 실시간 영상이라면 초당 약 ${(1000 / perFrame).toFixed(1)}장을 처리할 수 있는 속도입니다.`
: "한 장 처리 시간이 1 ms보다 짧아 초당 처리 장수를 계산하지 않았습니다."}</p>`;
setStatus("속도 측정을 마쳤습니다.", "ok");
} catch (error) {
console.error(error);
setStatus(`속도 측정에 실패했습니다: ${error.message}`, "error");
} finally {
setBusy(false);
}
}
// =====================================================
// CSV 저장 (엑셀에서 한글이 깨지지 않도록 BOM 추가)
// =====================================================
function downloadCsv() {
if (!state.raw) return;
const visible = visibleDetections();
const ranks = closenessRanks(visible);
const lines = [];
lines.push(["파일", state.fileName]);
lines.push(["분석용 사진 크기", `${state.W}x${state.H}`]);
lines.push(["물체 탐지 모델", DETECT_MODEL]);
lines.push(["깊이 추정 모델", depthModelName ?? "사용 안 함"]);
lines.push(["기준 점수", currentThreshold().toFixed(2)]);
lines.push([]);
lines.push(["번호", "물체", "영문", "AI 확신도(%)", "깊이값(중앙값)", "근접도", "가까운 순위", "참고", "실측 거리(cm)", "추정 거리(cm)", "오차(%)"]);
for (const d of visible) {
const s = d.stats;
const loo = state.loo.get(d.id);
lines.push([
d.id,
labelKo(d.label),
d.label,
(d.score * 100).toFixed(1),
s ? s.median.toFixed(4) : "",
s ? s.closeness.toFixed(1) : "",
s ? ranks.get(d.id) : "",
warningsFor(s).join(" / "),
state.distances.get(d.id) ?? "",
loo?.pred != null ? loo.pred.toFixed(1) : (loo ? "추정 불가" : ""),
loo?.err != null ? loo.err.toFixed(1) : ""
]);
}
if (state.timing) {
lines.push([]);
lines.push(["단계", "처리 시간(ms)"]);
lines.push(["사진 준비", Math.round(state.timing.prep)]);
lines.push(["물체 탐지", Math.round(state.timing.detect)]);
lines.push(["깊이 추정", state.timing.depth === null ? "" : Math.round(state.timing.depth)]);
lines.push(["후처리·그리기", Math.round(state.timing.post)]);
}
if (state.bench) {
lines.push([]);
lines.push(["반복 측정", "회차별(ms)"]);
lines.push(["물체 탐지", ...state.bench.det.map(Math.round)]);
if (state.bench.dep.length) lines.push(["깊이 추정", ...state.bench.dep.map(Math.round)]);
}
const csv = "\uFEFF" + lines.map(r => r.map(csvCell).join(",")).join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// 일부 브라우저는 한글 파일명을 무시하고 'download'로 저장하므로 영문·숫자만 사용
const base = state.fileName.replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9_-]/g, "");
a.download = `depth-result_${base || "photo"}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// =====================================================
// 이벤트 연결 (모델을 불러오는 동안에도 조작부는 동작)
// =====================================================
els.threshold.addEventListener("input", () => {
els.thresholdValue.textContent = currentThreshold().toFixed(2);
if (state.raw) {
renderAll();
if (els.expOut.innerHTML) resetExperiment("기준 점수가 바뀌었습니다. 실측 비교를 다시 계산하세요.");
}
});
for (const radio of els.viewRadios) {
radio.addEventListener("change", () => {
if (state.raw) drawCanvas(visibleDetections(), closenessRanks(visibleDetections()));
});
}
// 창 크기가 바뀌면 이름표 크기와 그래프를 다시 맞춤
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (state.raw) drawCanvas(visibleDetections(), closenessRanks(visibleDetections()));
const plot = $("plot");
if (state.plot && plot) drawPlot(plot, state.plot.points, state.plot.fit);
}, 150);
});
els.run.addEventListener("click", analyze);
els.exp.addEventListener("click", runExperiment);
els.bench.addEventListener("click", runBenchmark);
els.csv.addEventListener("click", downloadCsv);
els.file.addEventListener("change", () => {
if (els.file.files[0] && detector !== null) {
setStatus(`'${els.file.files[0].name}'을(를) 골랐습니다. 분석하기를 누르세요.`);
}
});
loadModels();
</script>
</body>
</html>