Spaces:
Running
Running
Download index.html from dobby23/Monocular-Depth-Lab: direct link, hf CLI and curl.
- Browser
- Download file 58.3 kB
-
https://huggingface.co/spaces/dobby23/Monocular-Depth-Lab/resolve/main/index.html
- Command line
-
hf download hf://spaces/dobby23/Monocular-Depth-Lab/index.html
-
curl -L -o index.html https://huggingface.co/spaces/dobby23/Monocular-Depth-Lab/resolve/main/index.html
58.3 kB
| <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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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> | |