dobby23 commited on
Commit
8379480
·
verified ·
1 Parent(s): 6a22b8a

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +1333 -389
index.html CHANGED
@@ -3,582 +3,1526 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
6
 
7
- <title>AI 사진 속 물체 카운터</title>
 
 
8
 
9
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  body {
11
- font-family: "맑은 고딕", sans-serif;
12
- max-width: 800px;
13
- margin: 24px auto;
14
- padding: 0 16px;
15
- color: #202124;
 
16
  }
17
 
 
 
 
18
  h1 {
19
- font-size: 26px;
20
- margin-bottom: 6px;
 
 
 
21
  }
22
 
23
- .sub {
24
- color: #5f6368;
25
- margin-top: 0;
26
- }
27
 
28
- #status {
29
- display: block;
30
- margin: 14px 0;
31
- padding: 10px 12px;
32
- background: #f1f3f4;
33
  border-radius: 6px;
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
- .control {
37
- margin: 15px 0;
 
 
 
 
38
  }
39
 
40
- input[type="file"] {
41
- margin-top: 8px;
 
 
 
 
 
 
 
 
 
42
  }
43
 
44
- input[type="range"] {
45
- width: 300px;
 
 
 
 
 
 
 
46
  }
47
 
48
- button {
49
- padding: 9px 16px;
50
- margin-top: 10px;
 
 
 
 
 
 
 
 
 
51
  cursor: pointer;
52
- font-size: 15px;
53
  }
 
 
 
54
 
55
- #canvas {
56
- width: 100%;
57
- max-width: 720px;
58
- height: auto;
59
- margin-top: 15px;
60
- border: 1px solid #dadce0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  border-radius: 6px;
62
- display: none;
 
 
63
  }
64
 
65
- #result {
66
- margin-top: 16px;
67
- padding: 14px;
68
- min-height: 60px;
69
- border: 1px solid #dadce0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  border-radius: 6px;
71
- white-space: pre-line;
72
- line-height: 1.7;
73
  }
74
 
75
- .label {
76
- font-weight: bold;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  }
78
  </style>
79
  </head>
80
 
81
  <body>
82
 
83
- <h1>📷AI 사진 속 물체 카운터</h1>
84
-
85
- <p class="sub">
86
- 사진을 올리면 AI가 물체의 위치를 찾고,
87
- 종류별 개수와 전체 개수를 분석합니다.
88
- </p>
89
-
90
-
91
- <!-- =====================================================
92
- 입력 1 : 이미지
93
- ===================================================== -->
94
-
95
- <div class="control">
96
-
97
- <div class="label">① 사진 선택</div>
98
-
99
- <input
100
- type="file"
101
- id="imageInput"
102
- accept="image/*"
103
- />
104
-
105
- </div>
106
-
107
-
108
- <!-- =====================================================
109
- 입력 2 : 기준 점수
110
- ===================================================== -->
111
-
112
- <div class="control">
113
-
114
- <div class="label">
115
- ② 기준 점수 :
116
- <span id="thresholdValue">0.70</span>
117
  </div>
118
-
119
- <input
120
- type="range"
121
- id="threshold"
122
- min="0.1"
123
- max="0.95"
124
- step="0.05"
125
- value="0.70"
126
- />
127
-
128
- </div>
129
-
130
-
131
- <button id="runButton">
132
- 🔍 물체 분석하기
133
- </button>
134
-
135
-
136
- <!-- 상태 표시 -->
137
- <span id="status">
138
- AI 모델 불러오는 중...
139
- </span>
140
-
141
-
142
- <!-- 결과 이미지 -->
143
- <canvas id="canvas"></canvas>
144
-
145
-
146
- <!-- 개수 결과 -->
147
- <div id="result">
148
- 사진을 선택한 뒤 물체 분석하기 버튼을 눌러 주세요.
149
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
 
152
  <script type="module">
153
 
154
- import { pipeline }
155
  from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.6";
156
 
157
 
158
  // =====================================================
159
- // 모델 설정
160
  // =====================================================
161
 
162
- const TASK = "object-detection";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
- const MODEL_NAME =
165
- "Xenova/yolos-tiny";
166
 
167
  // =====================================================
168
- // 물체 이름 한글 표시
169
  // =====================================================
170
-
171
  const LABEL_KO = {
172
- person: "사람",
173
- bicycle: "자전거",
174
- car: "자동차",
175
- motorcycle: "오토바이",
176
- bus: "버스",
177
- truck: "트럭",
178
- dog: "강아지",
179
- cat: "고양이",
180
- bird: "새",
181
- horse: "말",
182
- chair: "의자",
183
- couch: "소파",
184
- bottle: "병",
185
- cup: "컵",
186
- laptop: "노트북",
187
- book: "책"
 
 
 
 
 
188
  };
189
 
190
- function getLabelName(label) {
191
-
192
- return LABEL_KO[label]
193
- ? `${LABEL_KO[label]}(${label})`
194
- : label;
195
-
196
- }
197
-
198
-
199
-
200
-
201
-
202
 
203
  // =====================================================
204
- // HTML 요소
 
205
  // =====================================================
206
 
207
- const imageInput =
208
- document.getElementById("imageInput");
209
-
210
- const threshold =
211
- document.getElementById("threshold");
212
-
213
- const thresholdValue =
214
- document.getElementById("thresholdValue");
215
-
216
- const runButton =
217
- document.getElementById("runButton");
218
-
219
- const statusBox =
220
- document.getElementById("status");
221
-
222
- const resultBox =
223
- document.getElementById("result");
224
-
225
- const canvas =
226
- document.getElementById("canvas");
227
 
228
- const ctx =
229
- canvas.getContext("2d");
 
230
 
 
 
 
 
 
 
 
 
231
 
232
- // =====================================================
233
- // 기준 점수 숫자 표시
234
- // =====================================================
235
 
236
- threshold.addEventListener(
237
- "input",
238
- () => {
239
 
240
- thresholdValue.textContent =
241
- Number(threshold.value).toFixed(2);
 
 
 
 
 
 
 
 
 
 
 
 
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  }
244
- );
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
- // =====================================================
248
- // AI 모델 불러오기
249
- // =====================================================
 
 
 
 
 
250
 
251
- let detector = null;
252
- let loadSeconds = "";
 
 
 
 
 
 
 
 
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
- const start =
258
- performance.now();
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
 
 
 
 
260
 
261
- detector =
262
- await pipeline(
263
- TASK,
264
- MODEL_NAME
265
- );
 
266
 
 
 
 
 
 
 
 
 
 
 
267
 
268
- loadSeconds =
269
- (
270
- (performance.now() - start)
271
- / 1000
272
- ).toFixed(1);
273
 
 
 
 
 
274
 
275
- statusBox.textContent =
276
- `준비 완료 · 모델 불러오기 ${loadSeconds}초`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
 
 
278
 
279
- } catch (error) {
280
 
281
- statusBox.textContent =
282
- `❌ 모델을 불러오지 못함 : ${error.message}`;
283
 
284
- console.error(error);
 
 
 
285
 
 
 
286
  }
287
 
288
 
289
  // =====================================================
290
- // 분석 버튼
291
  // =====================================================
292
 
293
- runButton.addEventListener(
294
- "click",
295
- analyzeImage
296
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
 
299
  // =====================================================
300
- // 사진 분석
301
  // =====================================================
302
 
303
- async function analyzeImage() {
304
-
305
- if (detector === null) {
306
-
307
- resultBox.textContent =
308
- "AI 모델을 아직 불러오는 중입니다.";
309
-
310
- return;
311
-
312
- }
313
-
314
-
315
- const file =
316
- imageInput.files[0];
 
 
 
 
 
 
 
 
 
 
317
 
318
 
319
- if (!file) {
 
 
320
 
321
- resultBox.textContent =
322
- "사진을 먼저 선택해 주세요.";
 
 
323
 
324
- return;
 
 
 
 
 
 
325
 
 
 
326
  }
 
327
 
 
 
 
 
328
 
329
- const score =
330
- Number(threshold.value);
331
-
332
-
333
- statusBox.textContent =
334
- "물체를 찾는 중...";
335
-
336
-
337
- resultBox.textContent =
338
- "분석 중...";
339
-
340
-
341
- // -----------------------------------------------------
342
- // 이미지 URL 생성
343
- // -----------------------------------------------------
344
-
345
- const imageURL =
346
- URL.createObjectURL(file);
347
-
348
-
349
- const image =
350
- new Image();
351
-
352
 
353
- image.onload =
354
- async () => {
 
 
355
 
356
- try {
 
 
 
357
 
358
- // ------------------------------------------------
359
- // Canvas에 원본 이미지 표시
360
- // ------------------------------------------------
361
 
362
- canvas.width =
363
- image.naturalWidth;
 
364
 
365
- canvas.height =
366
- image.naturalHeight;
 
 
 
367
 
 
 
 
 
 
 
 
 
368
 
369
- ctx.clearRect(
370
- 0,
371
- 0,
372
- canvas.width,
373
- canvas.height
374
- );
375
 
 
 
 
376
 
377
- ctx.drawImage(
378
- image,
379
- 0,
380
- 0
381
- );
 
 
 
 
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
 
384
- canvas.style.display =
385
- "block";
386
 
 
387
 
388
- // ------------------------------------------------
389
- // YOLOS 물체 탐지
390
- // ------------------------------------------------
 
 
 
 
 
 
 
391
 
392
- const start =
393
- performance.now();
 
394
 
 
 
 
 
 
 
 
 
395
 
396
- const output =
397
- await detector(
398
- imageURL,
399
- {
400
- threshold: score
401
- }
402
- );
403
 
 
 
 
404
 
405
- const seconds =
406
- (
407
- (performance.now() - start)
408
- / 1000
409
- ).toFixed(1);
 
 
 
 
410
 
 
 
 
 
 
411
 
412
- // ------------------------------------------------
413
- // 개수 저장
414
- // ------------------------------------------------
 
 
415
 
416
- const counts = {};
 
417
 
 
 
418
 
419
- // ------------------------------------------------
420
- // 찾은 물체 하나씩 처리
421
- // ------------------------------------------------
422
 
423
- for (const item of output) {
 
 
424
 
425
- const label =
426
- item.label;
 
427
 
428
- const score =
429
- item.score;
 
 
 
 
 
430
 
431
- const box =
432
- item.box;
 
 
433
 
 
 
434
 
435
- // 개수 증가
436
- if (counts[label]) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
 
438
- counts[label]++;
439
 
440
- } else {
 
 
 
441
 
442
- counts[label] = 1;
 
 
 
 
443
 
444
- }
 
 
 
 
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
 
447
- // ----------------------------------------------
448
- // 네모 박스 그리기
449
- // ----------------------------------------------
 
 
 
 
450
 
451
- const x =
452
- box.xmin;
453
 
454
- const y =
455
- box.ymin;
 
456
 
457
- const width =
458
- box.xmax - box.xmin;
 
 
 
 
 
459
 
460
- const height =
461
- box.ymax - box.ymin;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
 
464
- ctx.lineWidth = 4;
 
465
 
466
- ctx.strokeStyle =
467
- "#ff3366";
468
 
469
- ctx.strokeRect(
470
- x,
471
- y,
472
- width,
473
- height
474
- );
475
 
 
 
476
 
477
- // ----------------------------------------------
478
- // 물체 이름 표시
479
- // ----------------------------------------------
480
 
481
- const text =
482
- `${getLabelName(label)} ${(score * 100).toFixed(0)}%`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
 
 
 
484
 
485
- ctx.font =
486
- "20px Arial";
487
 
 
 
 
488
 
489
- const textWidth =
490
- ctx.measureText(text).width;
 
 
 
491
 
 
 
 
 
 
492
 
493
- ctx.fillStyle =
494
- "#ff3366";
 
495
 
 
 
 
 
496
 
497
- ctx.fillRect(
498
- x,
499
- Math.max(0, y - 28),
500
- textWidth + 12,
501
- 28
502
- );
503
 
 
 
 
 
 
 
504
 
505
- ctx.fillStyle =
506
- "white";
507
 
 
 
 
508
 
509
- ctx.fillText(
510
- text,
511
- x + 6,
512
- Math.max(20, y - 7)
513
- );
514
 
515
- }
 
 
 
 
516
 
 
517
 
518
- // ------------------------------------------------
519
- // 결과 문장 만들기
520
- // ------------------------------------------------
 
 
 
521
 
522
- if (output.length === 0) {
 
 
 
 
523
 
524
- resultBox.textContent =
525
- "기준 점수 이상인 물체를 찾지 못했습니다.";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
 
527
- } else {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
 
529
- let resultText =
530
- "📊 물체 분석 결과\n\n";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
- for (
534
- const [label, count]
535
- of Object.entries(counts)
536
- ) {
 
 
 
 
 
 
 
537
 
538
- resultText +=
539
- `${getLabelName(label)} : ${count}개\n`;
540
 
541
- }
 
 
542
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
- resultText +=
545
- `\n총 ${output.length}개의 물체를 찾았습니다.`;
 
546
 
 
 
547
 
548
- resultBox.textContent =
549
- resultText;
 
550
 
551
- }
 
 
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
 
554
- statusBox.textContent =
555
- `완료 · 분석 ${seconds}초 · 기준 점수 ${score.toFixed(2)} · (모델 불러오기 ${loadSeconds}초)`;
556
 
 
 
 
557
 
558
- } catch (error) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
 
560
- statusBox.textContent =
561
- `❌ 분석 실패 : ${error.message}`;
 
 
 
 
 
 
562
 
563
- console.error(error);
 
 
 
 
 
564
 
565
- } finally {
 
 
 
 
 
 
 
 
 
 
 
 
566
 
567
- URL.revokeObjectURL(
568
- imageURL
569
- );
570
 
571
- }
 
 
572
 
573
- };
 
 
 
 
 
 
574
 
 
 
 
 
 
575
 
576
- image.src =
577
- imageURL;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
- }
580
 
581
  </script>
582
 
583
  </body>
584
- </html>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>사진 한 장으로 거리를 잴 수 있을까? — AI 물체 탐지 + 깊이 추정</title>
7
 
8
+ <!-- 글꼴: 불러오지 못하면 맑은 고딕으로 표시됨 -->
9
+ <link rel="stylesheet"
10
+ href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
11
 
12
  <style>
13
+ :root {
14
+ --ink: #18202e;
15
+ --muted: #5b6474;
16
+ --paper: #f3f5f8;
17
+ --panel: #ffffff;
18
+ --line: #d8dde5;
19
+ --accent: #5b1a9e; /* 깊이 색상표의 '먼 쪽' 보라 */
20
+ --near: #f89540; /* 깊이 색상표의 '가까운 쪽' 주황 */
21
+ --warn: #9a4314;
22
+ --ok: #1f6f4a;
23
+ --error: #8a1f1f;
24
+ }
25
+
26
+ * { box-sizing: border-box; }
27
+
28
  body {
29
+ margin: 0;
30
+ background: var(--paper);
31
+ color: var(--ink);
32
+ font-family: "Pretendard Variable", Pretendard, "Apple SD Gothic Neo", "Malgun Gothic", "맑은 고딕", sans-serif;
33
+ line-height: 1.6;
34
+ font-variant-numeric: tabular-nums;
35
  }
36
 
37
+ header, main { max-width: 1120px; margin: 0 auto; padding-left: 20px; padding-right: 20px; }
38
+ header { padding-top: 32px; padding-bottom: 8px; }
39
+
40
  h1 {
41
+ font-size: clamp(24px, 3.4vw, 34px);
42
+ line-height: 1.25;
43
+ letter-spacing: -0.02em;
44
+ font-weight: 750;
45
+ margin: 0 0 8px;
46
  }
47
 
48
+ .lede { color: var(--muted); margin: 0; max-width: 64ch; }
 
 
 
49
 
50
+ /* 깊이 색상표 범례 — 화면에서 가장 눈에 띄는 요소 */
51
+ .legend { margin-top: 18px; max-width: 440px; }
52
+ .legend-bar {
53
+ height: 12px;
 
54
  border-radius: 6px;
55
+ background: linear-gradient(90deg, #0d0887, #6a00a8, #b12a90, #e16462, #fca636, #f0f921);
56
+ }
57
+ .legend-labels { display: flex; justify-content: space-between; font-size: 13px; color: var(--muted); margin-top: 4px; }
58
+
59
+ main {
60
+ padding-top: 16px;
61
+ padding-bottom: 56px;
62
+ display: grid;
63
+ grid-template-columns: 300px minmax(0, 1fr);
64
+ gap: 20px;
65
+ align-items: start;
66
  }
67
 
68
+ .panel {
69
+ background: var(--panel);
70
+ border: 1px solid var(--line);
71
+ border-radius: 10px;
72
+ padding: 18px;
73
+ min-width: 0;
74
  }
75
 
76
+ .wide { grid-column: 1 / -1; }
77
+
78
+ h2 { font-size: 18px; margin: 0; font-weight: 700; letter-spacing: -0.01em; }
79
+
80
+ .section-head {
81
+ display: flex;
82
+ justify-content: space-between;
83
+ align-items: center;
84
+ gap: 12px;
85
+ flex-wrap: wrap;
86
+ margin-bottom: 12px;
87
  }
88
 
89
+ .hint { font-size: 13px; color: var(--muted); margin: 6px 0 0; }
90
+
91
+ /* ---------- 왼쪽 조작부 ---------- */
92
+ .step { margin-bottom: 20px; }
93
+ .step-title { display: flex; gap: 8px; align-items: center; font-weight: 650; margin-bottom: 8px; }
94
+ .step-num {
95
+ display: inline-grid; place-items: center;
96
+ width: 22px; height: 22px; border-radius: 50%;
97
+ background: var(--ink); color: #fff; font-size: 12px; font-weight: 700;
98
  }
99
 
100
+ input[type="file"] { width: 100%; font: inherit; font-size: 14px; }
101
+ input[type="range"] { width: 100%; accent-color: var(--accent); }
102
+
103
+ .btn {
104
+ appearance: none;
105
+ border: 1px solid var(--accent);
106
+ background: var(--accent);
107
+ color: #fff;
108
+ font: inherit;
109
+ font-weight: 650;
110
+ padding: 10px 16px;
111
+ border-radius: 8px;
112
  cursor: pointer;
 
113
  }
114
+ .btn.block { width: 100%; }
115
+ .btn.secondary { background: #fff; color: var(--accent); }
116
+ .btn:disabled { opacity: 0.45; cursor: not-allowed; }
117
 
118
+ :focus-visible { outline: 3px solid var(--near); outline-offset: 2px; }
119
+
120
+ .status {
121
+ margin-top: 14px;
122
+ padding: 10px 12px;
123
+ border-radius: 8px;
124
+ background: #eef1f5;
125
+ font-size: 14px;
126
+ }
127
+ .status[data-tone="error"] { background: #fbeaea; color: var(--error); }
128
+ .status[data-tone="warn"] { background: #fdf2e3; color: var(--warn); }
129
+ .status[data-tone="ok"] { background: #e7f3ec; color: var(--ok); }
130
+
131
+ .seg { display: flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
132
+ .seg label { flex: 1; position: relative; cursor: pointer; border-right: 1px solid var(--line); }
133
+ .seg label:last-child { border-right: 0; }
134
+ .seg input { position: absolute; opacity: 0; pointer-events: none; }
135
+ .seg span { display: block; text-align: center; padding: 7px 0; font-size: 14px; }
136
+ .seg input:checked + span { background: var(--ink); color: #fff; }
137
+ .seg input:focus-visible + span { outline: 3px solid var(--near); outline-offset: -3px; }
138
+ .seg input:disabled + span { opacity: 0.4; cursor: not-allowed; }
139
+
140
+ /* ---------- 결과 화면 ---------- */
141
+ #canvas { display: none; width: 100%; height: auto; border-radius: 6px; }
142
+ .placeholder {
143
+ display: grid; place-items: center;
144
+ min-height: 320px;
145
+ border: 1px dashed var(--line);
146
  border-radius: 6px;
147
+ color: var(--muted);
148
+ text-align: center;
149
+ padding: 20px;
150
  }
151
 
152
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; padding: 0; list-style: none; }
153
+ .chips li { background: #eef1f5; border-radius: 999px; padding: 3px 12px; font-size: 14px; }
154
+ .chips li.total { background: var(--ink); color: #fff; }
155
+
156
+ .table-wrap { overflow-x: auto; }
157
+ table { border-collapse: collapse; width: 100%; font-size: 14px; }
158
+ th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
159
+ th { font-weight: 650; color: var(--muted); font-size: 13px; }
160
+ td.num, th.num { text-align: right; }
161
+ .objects { min-width: 760px; }
162
+
163
+ .closeness { display: inline-flex; align-items: center; gap: 8px; }
164
+ .closeness i { display: inline-block; width: 14px; height: 14px; border-radius: 3px; border: 1px solid rgba(0,0,0,.15); }
165
+
166
+ .flag { color: var(--warn); font-size: 13px; }
167
+
168
+ input.dist {
169
+ width: 96px;
170
+ font: inherit;
171
+ padding: 4px 8px;
172
+ border: 1px solid var(--line);
173
  border-radius: 6px;
174
+ text-align: right;
 
175
  }
176
 
177
+ .empty { color: var(--muted); padding: 12px 0; margin: 0; }
178
+
179
+ /* ---------- 실측 비교 ---------- */
180
+ .exp-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; margin-top: 14px; }
181
+ .stat-list { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; margin: 0 0 12px; }
182
+ .stat-list dt { color: var(--muted); }
183
+ .stat-list dd { margin: 0; font-weight: 650; }
184
+ .notes { margin: 0; padding-left: 18px; }
185
+ .notes li { margin-bottom: 6px; }
186
+ #plot { width: 100%; display: block; border: 1px solid var(--line); border-radius: 6px; }
187
+
188
+ /* ---------- 처리 시간 ---------- */
189
+ .bars { display: grid; gap: 8px; margin-bottom: 10px; }
190
+ .bar-row { display: grid; grid-template-columns: 120px minmax(0, 1fr) 90px; gap: 10px; align-items: center; font-size: 14px; }
191
+ .bar { height: 10px; background: #e6e9ef; border-radius: 5px; overflow: hidden; }
192
+ .bar > i { display: block; height: 100%; background: var(--accent); }
193
+ .bar-row .ms { text-align: right; }
194
+ .bench-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
195
+
196
+ @media (max-width: 860px) {
197
+ main { grid-template-columns: minmax(0, 1fr); }
198
+ .exp-grid { grid-template-columns: minmax(0, 1fr); }
199
+ .placeholder { min-height: 200px; }
200
  }
201
  </style>
202
  </head>
203
 
204
  <body>
205
 
206
+ <header>
207
+ <h1>사진 한 장으로 거리를 잴 수 있을까?</h1>
208
+ <p class="lede">
209
+ 물체 탐지 모델(YOLOS)로 물체를 찾고, 깊이 추정 모델(Depth Anything V2)로 각 물체가 얼마나 가까운지 계산합니다.
210
+ 줄자로 잰 실제 거리를 입력하면 AI의 값이 얼마나 맞는지 비교합니다.
211
+ </p>
212
+ <div class="legend" aria-hidden="true">
213
+ <div class="legend-bar"></div>
214
+ <div class="legend-labels"><span>멀다</span><span>깊이맵 색상</span><span>가깝다</span></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  </div>
216
+ </header>
217
+
218
+ <main>
219
+
220
+ <!-- =====================================================
221
+ 왼쪽: 조작부
222
+ ===================================================== -->
223
+ <aside class="panel">
224
+
225
+ <div class="step">
226
+ <label class="step-title" for="imageInput"><span class="step-num">1</span>사진 고르기</label>
227
+ <input type="file" id="imageInput" accept="image/*" />
228
+ <p class="hint">실측 실험용이면 같은 종류의 물체 3~5개를 거리만 다르게 놓고 한 장에 찍으세요.</p>
229
+ </div>
230
+
231
+ <div class="step">
232
+ <label class="step-title" for="threshold">
233
+ <span class="step-num">2</span>기준 점수 <output id="thresholdValue" for="threshold">0.70</output>
234
+ </label>
235
+ <input type="range" id="threshold" min="0.1" max="0.95" step="0.05" value="0.70" />
236
+ <p class="hint">AI의 확신이 이 점수보다 낮은 물체는 숨깁니다. 다시 분석하지 않고 바로 반영됩니다.</p>
237
+ </div>
238
+
239
+ <button class="btn block" id="runButton" disabled>분석하기</button>
240
+
241
+ <div class="status" id="status" role="status" aria-live="polite">AI 모델을 불러오는 중입니다.</div>
242
+
243
+ <div class="step" style="margin-top:20px;margin-bottom:0">
244
+ <div class="step-title">화면 보기</div>
245
+ <div class="seg" role="radiogroup" aria-label="화면 보기">
246
+ <label><input type="radio" name="view" value="photo" checked /><span>사진</span></label>
247
+ <label><input type="radio" name="view" value="depth" disabled /><span>깊이맵</span></label>
248
+ <label><input type="radio" name="view" value="blend" disabled /><span>겹쳐 보기</span></label>
249
+ </div>
250
+ </div>
251
+
252
+ </aside>
253
+
254
+ <!-- =====================================================
255
+ 오른쪽: 결과 이미지
256
+ ===================================================== -->
257
+ <section class="panel" aria-label="분석 결과 이미지">
258
+ <div class="placeholder" id="placeholder">사진을 고르고 분석하면 여기에 결과가 표시됩니다.</div>
259
+ <canvas id="canvas"></canvas>
260
+ </section>
261
+
262
+ <!-- =====================================================
263
+ 찾은 물체 표
264
+ ===================================================== -->
265
+ <section class="panel wide">
266
+ <div class="section-head">
267
+ <h2>찾은 물체</h2>
268
+ <button class="btn secondary" id="csvButton" disabled>결과를 CSV로 저장</button>
269
+ </div>
270
+
271
+ <ul class="chips" id="summary"></ul>
272
+
273
+ <div class="table-wrap">
274
+ <table class="objects">
275
+ <thead>
276
+ <tr>
277
+ <th>번호</th>
278
+ <th>물체</th>
279
+ <th class="num">AI 확신도</th>
280
+ <th class="num">깊이값(중앙값)</th>
281
+ <th>근접도</th>
282
+ <th class="num">가까운 순위</th>
283
+ <th>참고</th>
284
+ <th>실측 거리(cm)</th>
285
+ </tr>
286
+ </thead>
287
+ <tbody id="objectRows"></tbody>
288
+ </table>
289
+ </div>
290
+ <p class="empty" id="tableEmpty">아직 분석한 사진이 없습니다.</p>
291
+ <p class="hint">
292
+ 깊이값은 물체 상자의 가운데 50% 영역에서 구한 중앙값입니다. 상자 가장자리에 섞여 들어오는 배경을 줄이기 위해서입니다.
293
+ 근접도는 이 사진에서 가장 먼 곳을 0, 가장 가까운 곳을 100으로 바꾼 값입니다.
294
+ </p>
295
+ </section>
296
+
297
+ <!-- =====================================================
298
+ 실측 비교 실험
299
+ ===================================================== -->
300
+ <section class="panel wide">
301
+ <div class="section-head">
302
+ <h2>실측 비교</h2>
303
+ <button class="btn" id="expButton" disabled>실측 비교 계산</button>
304
+ </div>
305
+ <p class="hint" style="margin-top:0">
306
+ 표의 마지막 칸에 줄자로 잰 카메라–물체 거리(cm)를 3개 이상 입력하고 계산하세요.
307
+ 깊이값은 사진마다 기준이 달라지므로, 같은 사진 안의 물체끼리만 비교합니다.
308
+ </p>
309
+ <div id="expOutput"></div>
310
+ </section>
311
+
312
+ <!-- =====================================================
313
+ 처리 시간
314
+ ===================================================== -->
315
+ <section class="panel wide">
316
+ <div class="section-head">
317
+ <h2>처리 시간</h2>
318
+ </div>
319
+ <div id="timing"><p class="empty">분석을 하면 단계별 처리 시간이 표시됩니다.</p></div>
320
+ <p class="hint" id="loadInfo"></p>
321
+ <div class="bench-actions">
322
+ <button class="btn secondary" id="benchButton" disabled>속도 5회 측정</button>
323
+ <span class="hint" style="margin:0">첫 분석에는 초기화 시간이 섞이므로, 반복 측정한 중앙값을 기준으로 삼습니다.</span>
324
+ </div>
325
+ <div id="benchOutput"></div>
326
+ </section>
327
+
328
+ </main>
329
 
330
 
331
  <script type="module">
332
 
333
+ import { pipeline, RawImage }
334
  from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.6";
335
 
336
 
337
  // =====================================================
338
+ // 설정
339
  // =====================================================
340
 
341
+ const DETECT_MODEL = "Xenova/yolos-tiny";
342
+
343
+ // 첫 번째 모델을 못 불러오면 두 번째로 다시 시도
344
+ const DEPTH_MODELS = [
345
+ "onnx-community/depth-anything-v2-small",
346
+ "onnx-community/depth-anything-v2-small-ONNX",
347
+ "Xenova/depth-anything-small-hf"
348
+ ];
349
+
350
+ const MAX_SIDE = 960; // 분석용 사진의 긴 변 최대 크기(px). 휴대폰 원본 사진은 너무 커서 줄임
351
+ const BASE_THRESHOLD = 0.1; // 탐지는 낮은 기준으로 한 번만 하고, 슬라이더로 걸러서 보여줌
352
+ const CENTER_RATIO = 0.5; // 깊이값을 구할 때 쓰는 상자 가운데 영역 비율
353
+ const SPREAD_WARN = 0.2; // 상자 안 깊이 편차가 이보다 크면 '배경 섞임' 경고
354
+ const SMALL_BOX_WARN = 0.01; // 사진 면적의 1%보다 작은 물체는 '작은 물체' 경고
355
+ const MAX_SAMPLES = 40000; // 상자 하나에서 읽을 깊이 픽셀 최대 개수
356
+ const BENCH_RUNS = 5;
357
 
 
 
358
 
359
  // =====================================================
360
+ // 물체 이름 한글 표시 (COCO 80종 전체)
361
  // =====================================================
362
+
363
  const LABEL_KO = {
364
+ "person": "사람", "bicycle": "자전거", "car": "자동차", "motorcycle": "오토바이",
365
+ "airplane": "비행기", "bus": "버스", "train": "기차", "truck": "트럭", "boat": "보트",
366
+ "traffic light": "신호등", "fire hydrant": "소화전", "stop sign": "정지 표지판",
367
+ "parking meter": "주차 요금기", "bench": "벤치", "bird": "새", "cat": "고양이",
368
+ "dog": "강아지", "horse": "말", "sheep": "양", "cow": "소", "elephant": "코끼리",
369
+ "bear": "곰", "zebra": "얼룩말", "giraffe": "기린", "backpack": "배낭",
370
+ "umbrella": "우산", "handbag": "핸드백", "tie": "넥타이", "suitcase": "여행가방",
371
+ "frisbee": "원반", "skis": "스키", "snowboard": "스노보드", "sports ball": "공",
372
+ "kite": "연", "baseball bat": "야구 배트", "baseball glove": "야구 글러브",
373
+ "skateboard": "스케이트보드", "surfboard": "서핑보드", "tennis racket": "테니스 라켓",
374
+ "bottle": "병", "wine glass": "와인잔", "cup": "컵", "fork": "포크", "knife": "칼",
375
+ "spoon": "숟가락", "bowl": "그릇", "banana": "바나나", "apple": "사과",
376
+ "sandwich": "샌드위치", "orange": "오렌지", "broccoli": "브로콜리", "carrot": "당근",
377
+ "hot dog": "핫도그", "pizza": "피자", "donut": "도넛", "cake": "케이크",
378
+ "chair": "의자", "couch": "소파", "potted plant": "화분", "bed": "침대",
379
+ "dining table": "식탁", "toilet": "변기", "tv": "TV", "laptop": "노트북",
380
+ "mouse": "마우스", "remote": "리모컨", "keyboard": "키보드", "cell phone": "휴대폰",
381
+ "microwave": "전자레인지", "oven": "오븐", "toaster": "토스터", "sink": "싱크대",
382
+ "refrigerator": "냉장고", "book": "책", "clock": "시계", "vase": "꽃병",
383
+ "scissors": "가위", "teddy bear": "곰인형", "hair drier": "헤어드라이어",
384
+ "toothbrush": "칫솔"
385
  };
386
 
 
 
 
 
 
 
 
 
 
 
 
 
387
 
388
  // =====================================================
389
+ // 계산 함수 (화면과 무관한 순수 계산)
390
+ // PURE-START
391
  // =====================================================
392
 
393
+ function labelKo(label) {
394
+ return LABEL_KO[label] ?? label;
395
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
+ function labelFull(label) {
398
+ return LABEL_KO[label] ? `${LABEL_KO[label]} (${label})` : label;
399
+ }
400
 
401
+ // 정렬된 배열에서 p 위치의 값 (0.5 = 중앙값)
402
+ function quantileSorted(sorted, p) {
403
+ if (sorted.length === 0) return NaN;
404
+ const pos = (sorted.length - 1) * p;
405
+ const lo = Math.floor(pos);
406
+ const hi = Math.ceil(pos);
407
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
408
+ }
409
 
410
+ function median(values) {
411
+ return quantileSorted(Float64Array.from(values).sort(), 0.5);
412
+ }
413
 
414
+ function clamp(v, lo, hi) {
415
+ return Math.min(hi, Math.max(lo, v));
416
+ }
417
 
418
+ // 깊이 배열의 최솟값·최댓값을 함께 담아 둠
419
+ function makeDepth(data, w, h) {
420
+ if (!data || data.length !== w * h) {
421
+ throw new Error(`깊이 데이터 크기가 맞지 않습니다 (${data?.length} ≠ ${w}×${h}).`);
422
+ }
423
+ let min = Infinity;
424
+ let max = -Infinity;
425
+ for (let i = 0; i < data.length; i++) {
426
+ const v = data[i];
427
+ if (v < min) min = v;
428
+ if (v > max) max = v;
429
+ }
430
+ return { data, w, h, min, max };
431
+ }
432
 
433
+ // 탐지 상자(사진 좌표)를 깊이맵 좌표로 옮겨, 가운데 영역의 깊이값 통계를 구함
434
+ // Depth Anything의 값은 '클수록 가깝다'
435
+ function boxDepthStats(depth, box, imgW, imgH) {
436
+ const sx = depth.w / imgW;
437
+ const sy = depth.h / imgH;
438
+
439
+ const bw = box.xmax - box.xmin;
440
+ const bh = box.ymax - box.ymin;
441
+ const margin = (1 - CENTER_RATIO) / 2;
442
+
443
+ let x0 = Math.floor((box.xmin + bw * margin) * sx);
444
+ let x1 = Math.ceil((box.xmax - bw * margin) * sx);
445
+ let y0 = Math.floor((box.ymin + bh * margin) * sy);
446
+ let y1 = Math.ceil((box.ymax - bh * margin) * sy);
447
+
448
+ x0 = clamp(x0, 0, depth.w - 1);
449
+ x1 = clamp(x1, x0 + 1, depth.w);
450
+ y0 = clamp(y0, 0, depth.h - 1);
451
+ y1 = clamp(y1, y0 + 1, depth.h);
452
+
453
+ const area = (x1 - x0) * (y1 - y0);
454
+ const step = Math.max(1, Math.ceil(Math.sqrt(area / MAX_SAMPLES)));
455
+
456
+ const values = [];
457
+ for (let y = y0; y < y1; y += step) {
458
+ for (let x = x0; x < x1; x += step) {
459
+ values.push(depth.data[y * depth.w + x]);
460
+ }
461
  }
 
462
 
463
+ const sorted = Float64Array.from(values).sort();
464
+ const med = quantileSorted(sorted, 0.5);
465
+ const q1 = quantileSorted(sorted, 0.25);
466
+ const q3 = quantileSorted(sorted, 0.75);
467
+ const range = (depth.max - depth.min) || 1;
468
+
469
+ return {
470
+ median: med,
471
+ q1,
472
+ q3,
473
+ samples: sorted.length,
474
+ closeness: ((med - depth.min) / range) * 100,
475
+ spread: (q3 - q1) / range,
476
+ areaRatio: (bw * bh) / (imgW * imgH)
477
+ };
478
+ }
479
 
480
+ // 실패 조건 힌트
481
+ function warningsFor(stats) {
482
+ const list = [];
483
+ if (!stats) return list;
484
+ if (stats.spread > SPREAD_WARN) list.push("상자 안 깊이 편차 큼(배경 섞임·기울어짐)");
485
+ if (stats.areaRatio < SMALL_BOX_WARN) list.push("작은 물체(픽셀 부족)");
486
+ return list;
487
+ }
488
 
489
+ // 가까운 순위: 깊이값이 클수록 1위
490
+ function closenessRanks(items) {
491
+ const ranked = items
492
+ .filter(d => d.stats)
493
+ .slice()
494
+ .sort((a, b) => b.stats.median - a.stats.median);
495
+ const ranks = new Map();
496
+ ranked.forEach((d, i) => ranks.set(d.id, i + 1));
497
+ return ranks;
498
+ }
499
 
500
+ // 두 물체씩 짝지어, '실제로 더 가까운 쪽의 깊이값이 더 큰가'를 셈
501
+ function pairwiseConcordance(points) {
502
+ let concordant = 0;
503
+ let total = 0;
504
+ for (let i = 0; i < points.length; i++) {
505
+ for (let j = i + 1; j < points.length; j++) {
506
+ const dd = points[i].d - points[j].d;
507
+ if (dd === 0) continue;
508
+ total++;
509
+ const dv = points[i].v - points[j].v;
510
+ if (dd * dv < 0) concordant++;
511
+ }
512
+ }
513
+ return { concordant, total };
514
+ }
515
 
516
+ // 피어슨 상관계수 (-1 ~ 1)
517
+ function pearson(xs, ys) {
518
+ const n = xs.length;
519
+ if (n < 2) return null;
520
+ const mx = xs.reduce((s, v) => s + v, 0) / n;
521
+ const my = ys.reduce((s, v) => s + v, 0) / n;
522
+ let sxy = 0, sxx = 0, syy = 0;
523
+ for (let i = 0; i < n; i++) {
524
+ sxy += (xs[i] - mx) * (ys[i] - my);
525
+ sxx += (xs[i] - mx) ** 2;
526
+ syy += (ys[i] - my) ** 2;
527
+ }
528
+ if (sxx === 0 || syy === 0) return null;
529
+ return sxy / Math.sqrt(sxx * syy);
530
+ }
531
 
532
+ // 최소제곱 직선 y = a·x + b
533
+ function linearFit(xs, ys) {
534
+ const n = xs.length;
535
+ if (n < 2) return null;
536
+ const mx = xs.reduce((s, v) => s + v, 0) / n;
537
+ const my = ys.reduce((s, v) => s + v, 0) / n;
538
+ let sxy = 0, sxx = 0;
539
+ for (let i = 0; i < n; i++) {
540
+ sxy += (xs[i] - mx) * (ys[i] - my);
541
+ sxx += (xs[i] - mx) ** 2;
542
+ }
543
+ if (sxx === 0) return null;
544
+ const a = sxy / sxx;
545
+ return { a, b: my - a * mx };
546
+ }
547
 
548
+ // 깊이값 = a·(100 / 거리cm) + b → 거리cm = 100·a / (깊이값 − b)
549
+ function inverseX(distanceCm) {
550
+ return 100 / distanceCm; // 1 / 거리(m)
551
+ }
552
 
553
+ function predictDistanceCm(value, fit) {
554
+ if (!fit || fit.a === 0) return null;
555
+ const x = (value - fit.b) / fit.a;
556
+ if (!(x > 0) || !Number.isFinite(x)) return null;
557
+ return 100 / x;
558
+ }
559
 
560
+ // 하나 빼고 맞히기: 나머지로 보정식을 만들고, 뺀 물체의 거리를 추정
561
+ function leaveOneOut(points) {
562
+ return points.map((p, i) => {
563
+ const others = points.filter((_, j) => j !== i);
564
+ const fit = linearFit(others.map(o => inverseX(o.d)), others.map(o => o.v));
565
+ const pred = predictDistanceCm(p.v, fit);
566
+ const err = pred === null ? null : (Math.abs(pred - p.d) / p.d) * 100;
567
+ return { id: p.id, d: p.d, v: p.v, pred, err };
568
+ });
569
+ }
570
 
571
+ function formatValue(v) {
572
+ if (v === null || v === undefined || Number.isNaN(v)) return "—";
573
+ return Math.abs(v) >= 100 ? v.toFixed(1) : v.toFixed(3);
574
+ }
 
575
 
576
+ function csvCell(v) {
577
+ const s = String(v ?? "");
578
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
579
+ }
580
 
581
+ // 깊이맵 색상표 (plasma 계열): 0 = 멀다(남보라), 1 = 가깝다(노랑)
582
+ function buildColorLUT() {
583
+ const stops = [
584
+ [0.00, [13, 8, 135]],
585
+ [0.20, [106, 0, 168]],
586
+ [0.40, [177, 42, 144]],
587
+ [0.60, [225, 100, 98]],
588
+ [0.80, [252, 166, 54]],
589
+ [1.00, [240, 249, 33]]
590
+ ];
591
+ const lut = new Uint8ClampedArray(256 * 3);
592
+ for (let i = 0; i < 256; i++) {
593
+ const t = i / 255;
594
+ let k = 0;
595
+ while (k < stops.length - 2 && t > stops[k + 1][0]) k++;
596
+ const [t0, c0] = stops[k];
597
+ const [t1, c1] = stops[k + 1];
598
+ const f = (t - t0) / (t1 - t0);
599
+ for (let c = 0; c < 3; c++) lut[i * 3 + c] = c0[c] + (c1[c] - c0[c]) * f;
600
+ }
601
+ return lut;
602
+ }
603
 
604
+ // PURE-END
605
+ // =====================================================
606
 
 
607
 
608
+ const LUT = buildColorLUT();
 
609
 
610
+ function colorFor(closeness) {
611
+ const i = clamp(Math.round((closeness / 100) * 255), 0, 255) * 3;
612
+ return [LUT[i], LUT[i + 1], LUT[i + 2]];
613
+ }
614
 
615
+ function esc(s) {
616
+ return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
617
  }
618
 
619
 
620
  // =====================================================
621
+ // HTML 요소
622
  // =====================================================
623
 
624
+ const $ = id => document.getElementById(id);
625
+
626
+ const els = {
627
+ file: $("imageInput"),
628
+ threshold: $("threshold"),
629
+ thresholdValue: $("thresholdValue"),
630
+ run: $("runButton"),
631
+ status: $("status"),
632
+ canvas: $("canvas"),
633
+ placeholder: $("placeholder"),
634
+ summary: $("summary"),
635
+ rows: $("objectRows"),
636
+ tableEmpty: $("tableEmpty"),
637
+ csv: $("csvButton"),
638
+ exp: $("expButton"),
639
+ expOut: $("expOutput"),
640
+ timing: $("timing"),
641
+ loadInfo: $("loadInfo"),
642
+ bench: $("benchButton"),
643
+ benchOut: $("benchOutput"),
644
+ viewRadios: document.querySelectorAll('input[name="view"]')
645
+ };
646
+
647
+ const ctx = els.canvas.getContext("2d");
648
 
649
 
650
  // =====================================================
651
+ // 상태
652
  // =====================================================
653
 
654
+ let detector = null;
655
+ let depthEstimator = null;
656
+ let depthModelName = null;
657
+
658
+ const loadTimes = { detect: null, depth: null };
659
+ const progress = { detect: new Map(), depth: new Map() };
660
+ const loadState = { detect: "loading", depth: "loading" };
661
+
662
+ const state = {
663
+ busy: false,
664
+ fileName: "",
665
+ W: 0,
666
+ H: 0,
667
+ srcCanvas: null, // 분석용 크기로 줄인 원본 사진
668
+ raw: null, // 모델에 넣는 RawImage
669
+ detections: [], // 기준 0.1로 찾은 전체 결과 (확신도 높은 순, id 고정)
670
+ depth: null, // { data, w, h, min, max }
671
+ depthCanvas: null, // 색을 입힌 깊이맵
672
+ distances: new Map(),// id → 실측 거리(cm)
673
+ loo: new Map(), // id → { pred, err }
674
+ timing: null,
675
+ bench: null,
676
+ plot: null // 그래프를 다시 그릴 때 쓰는 값
677
+ };
678
 
679
 
680
+ // =====================================================
681
+ // 공통 화면 갱신
682
+ // =====================================================
683
 
684
+ function setStatus(text, tone = "") {
685
+ els.status.textContent = text;
686
+ els.status.dataset.tone = tone;
687
+ }
688
 
689
+ function updateButtons() {
690
+ const hasImage = state.raw !== null;
691
+ els.run.disabled = state.busy || detector === null;
692
+ els.csv.disabled = state.busy || !hasImage;
693
+ els.bench.disabled = state.busy || !hasImage;
694
+ els.exp.disabled = state.busy || !hasImage || state.depth === null;
695
+ els.threshold.disabled = state.busy;
696
 
697
+ for (const radio of els.viewRadios) {
698
+ if (radio.value !== "photo") radio.disabled = state.depth === null;
699
  }
700
+ }
701
 
702
+ function setBusy(flag) {
703
+ state.busy = flag;
704
+ updateButtons();
705
+ }
706
 
707
+ function currentThreshold() {
708
+ return Number(els.threshold.value);
709
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
 
711
+ function currentView() {
712
+ for (const radio of els.viewRadios) if (radio.checked) return radio.value;
713
+ return "photo";
714
+ }
715
 
716
+ function visibleDetections() {
717
+ const thr = currentThreshold();
718
+ return state.detections.filter(d => d.score >= thr);
719
+ }
720
 
 
 
 
721
 
722
+ // =====================================================
723
+ // AI 모델 불러오기 (두 모델을 동시에 내려받음)
724
+ // =====================================================
725
 
726
+ function percentOf(map) {
727
+ let loaded = 0, total = 0;
728
+ for (const v of map.values()) { loaded += v.loaded; total += v.total; }
729
+ return total > 0 ? Math.round((loaded / total) * 100) : 0;
730
+ }
731
 
732
+ function progressHandler(key) {
733
+ return p => {
734
+ if (p.status === "progress" && p.file) {
735
+ progress[key].set(p.file, { loaded: p.loaded ?? 0, total: p.total ?? 0 });
736
+ renderLoadProgress();
737
+ }
738
+ };
739
+ }
740
 
741
+ function stateText(key) {
742
+ if (loadState[key] === "ready") return "준비됨";
743
+ if (loadState[key] === "failed") return "실패";
744
+ return `${percentOf(progress[key])}%`;
745
+ }
 
746
 
747
+ function renderLoadProgress() {
748
+ setStatus(`AI 모델을 내려받는 중입니다. 물체 탐지 ${stateText("detect")}, 깊이 추정 ${stateText("depth")}. 처음 한 번만 오래 걸립니다.`);
749
+ }
750
 
751
+ async function loadDetector() {
752
+ const start = performance.now();
753
+ detector = await pipeline("object-detection", DETECT_MODEL, {
754
+ progress_callback: progressHandler("detect")
755
+ });
756
+ loadTimes.detect = performance.now() - start;
757
+ loadState.detect = "ready";
758
+ renderLoadProgress();
759
+ }
760
 
761
+ async function loadDepthEstimator() {
762
+ let lastError = null;
763
+ for (const name of DEPTH_MODELS) {
764
+ try {
765
+ progress.depth.clear();
766
+ const start = performance.now();
767
+ depthEstimator = await pipeline("depth-estimation", name, {
768
+ progress_callback: progressHandler("depth")
769
+ });
770
+ loadTimes.depth = performance.now() - start;
771
+ depthModelName = name;
772
+ loadState.depth = "ready";
773
+ renderLoadProgress();
774
+ return;
775
+ } catch (error) {
776
+ console.warn(`깊이 모델 ${name} 불러오기 실패`, error);
777
+ lastError = error;
778
+ }
779
+ }
780
+ loadState.depth = "failed";
781
+ throw lastError;
782
+ }
783
 
784
+ async function loadModels() {
785
+ renderLoadProgress();
786
 
787
+ const [det, dep] = await Promise.allSettled([loadDetector(), loadDepthEstimator()]);
788
 
789
+ if (det.status === "rejected") {
790
+ loadState.detect = "failed";
791
+ console.error(det.reason);
792
+ setStatus(`물체 탐지 모델을 불러오지 못했습니다: ${det.reason?.message ?? det.reason}. 인터넷 연결을 확인하고 새로고침하세요.`, "error");
793
+ } else if (dep.status === "rejected") {
794
+ console.error(dep.reason);
795
+ setStatus("깊이 추정 모델을 불러오지 못해 물체 탐지만 합니다. 인터넷 연결을 확인하고 새로고침하면 다시 시도합니다.", "warn");
796
+ } else {
797
+ setStatus("준비됐습니다. 사진을 고르고 분석하세요.", "ok");
798
+ }
799
 
800
+ renderLoadInfo();
801
+ updateButtons();
802
+ }
803
 
804
+ function renderLoadInfo() {
805
+ const parts = [];
806
+ if (loadTimes.detect !== null) parts.push(`물체 탐지 ${(loadTimes.detect / 1000).toFixed(1)}초`);
807
+ if (loadTimes.depth !== null) parts.push(`깊이 추정 ${(loadTimes.depth / 1000).toFixed(1)}초`);
808
+ let text = parts.length ? `모델 불러오기 시간: ${parts.join(", ")}.` : "";
809
+ if (depthModelName) text += ` 사용한 깊이 모델: ${depthModelName}.`;
810
+ els.loadInfo.textContent = text;
811
+ }
812
 
 
 
 
 
 
 
 
813
 
814
+ // =====================================================
815
+ // 사진 준비: 분석용 크기로 줄이고 RawImage로 바꿈
816
+ // =====================================================
817
 
818
+ function loadImageElement(file) {
819
+ return new Promise((resolve, reject) => {
820
+ const url = URL.createObjectURL(file);
821
+ const img = new Image();
822
+ img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
823
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("사진을 열 수 없습니다. 다른 형식(JPG, PNG)으로 시도하세요.")); };
824
+ img.src = url;
825
+ });
826
+ }
827
 
828
+ async function prepareImage(file) {
829
+ const img = await loadImageElement(file);
830
+ const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth, img.naturalHeight));
831
+ const W = Math.max(1, Math.round(img.naturalWidth * scale));
832
+ const H = Math.max(1, Math.round(img.naturalHeight * scale));
833
 
834
+ const src = document.createElement("canvas");
835
+ src.width = W;
836
+ src.height = H;
837
+ const sctx = src.getContext("2d", { willReadFrequently: true });
838
+ sctx.drawImage(img, 0, 0, W, H);
839
 
840
+ const pixels = sctx.getImageData(0, 0, W, H).data;
841
+ const raw = new RawImage(pixels, W, H, 4).rgb();
842
 
843
+ return { W, H, src, raw };
844
+ }
845
 
 
 
 
846
 
847
+ // =====================================================
848
+ // 깊이 결과 읽기
849
+ // =====================================================
850
 
851
+ function readDepth(output) {
852
+ const result = Array.isArray(output) ? output[0] : output;
853
+ let tensor = result?.predicted_depth;
854
 
855
+ if (tensor?.dims && tensor?.data) {
856
+ if (tensor.type && tensor.type !== "float32" && typeof tensor.to === "function") {
857
+ tensor = tensor.to("float32");
858
+ }
859
+ const dims = tensor.dims;
860
+ return makeDepth(tensor.data, dims[dims.length - 1], dims[dims.length - 2]);
861
+ }
862
 
863
+ // 예비 경로: 0~255로 바뀐 깊이 이미지
864
+ if (result?.depth?.data) {
865
+ return makeDepth(result.depth.data, result.depth.width, result.depth.height);
866
+ }
867
 
868
+ throw new Error("깊이 결과 형식을 읽을 수 없습니다.");
869
+ }
870
 
871
+ function buildDepthCanvas(depth) {
872
+ const c = document.createElement("canvas");
873
+ c.width = depth.w;
874
+ c.height = depth.h;
875
+ const g = c.getContext("2d");
876
+ const img = g.createImageData(depth.w, depth.h);
877
+ const range = (depth.max - depth.min) || 1;
878
+
879
+ for (let i = 0; i < depth.data.length; i++) {
880
+ const k = clamp(Math.round(((depth.data[i] - depth.min) / range) * 255), 0, 255) * 3;
881
+ img.data[i * 4] = LUT[k];
882
+ img.data[i * 4 + 1] = LUT[k + 1];
883
+ img.data[i * 4 + 2] = LUT[k + 2];
884
+ img.data[i * 4 + 3] = 255;
885
+ }
886
+ g.putImageData(img, 0, 0);
887
+ return c;
888
+ }
889
 
 
890
 
891
+ // =====================================================
892
+ // 분석 (탐지 → 깊이 추정 → 물체별 깊이 계산)
893
+ // 처리 시간을 단계별로 재기 위해 두 모델을 차례로 실행함
894
+ // =====================================================
895
 
896
+ async function analyze() {
897
+ if (detector === null) {
898
+ setStatus("물체 탐지 모델이 아직 준비되지 않았습니다.", "warn");
899
+ return;
900
+ }
901
 
902
+ const file = els.file.files[0];
903
+ if (!file) {
904
+ setStatus("사진을 먼저 고르세요.", "warn");
905
+ return;
906
+ }
907
 
908
+ setBusy(true);
909
+
910
+ try {
911
+ const t0 = performance.now();
912
+ setStatus("사진을 준비하는 중입니다.");
913
+ const prepared = await prepareImage(file);
914
+ const t1 = performance.now();
915
+
916
+ setStatus("물체를 찾는 중입니다.");
917
+ const found = await detector(prepared.raw, { threshold: BASE_THRESHOLD });
918
+ const t2 = performance.now();
919
+
920
+ let depth = null;
921
+ let t3 = t2;
922
+ if (depthEstimator !== null) {
923
+ setStatus("깊이를 추정하는 중입니다.");
924
+ const out = await depthEstimator(prepared.raw);
925
+ depth = readDepth(out);
926
+ t3 = performance.now();
927
+ }
928
+
929
+ // ---------- 결과 저장 ----------
930
+ state.fileName = file.name;
931
+ state.W = prepared.W;
932
+ state.H = prepared.H;
933
+ state.srcCanvas = prepared.src;
934
+ state.raw = prepared.raw;
935
+ state.depth = depth;
936
+ state.depthCanvas = depth ? buildDepthCanvas(depth) : null;
937
+ state.distances = new Map();
938
+ state.loo = new Map();
939
+ state.bench = null;
940
+ els.benchOut.innerHTML = "";
941
+
942
+ state.detections = found
943
+ .slice()
944
+ .sort((a, b) => b.score - a.score)
945
+ .map((item, i) => ({
946
+ id: i + 1,
947
+ label: item.label,
948
+ score: item.score,
949
+ box: item.box,
950
+ stats: depth ? boxDepthStats(depth, item.box, prepared.W, prepared.H) : null
951
+ }));
952
+
953
+ if (!depth && currentView() !== "photo") {
954
+ els.viewRadios[0].checked = true;
955
+ }
956
+
957
+ renderAll();
958
+ resetExperiment();
959
+ const t4 = performance.now();
960
+
961
+ state.timing = {
962
+ prep: t1 - t0,
963
+ detect: t2 - t1,
964
+ depth: depth ? t3 - t2 : null,
965
+ post: t4 - t3
966
+ };
967
+ renderTiming();
968
+
969
+ const shown = visibleDetections().length;
970
+ setStatus(
971
+ shown > 0
972
+ ? `분석을 마쳤습니다. 기준 점수 이상인 물체 ${shown}개를 찾았습니다.`
973
+ : "분석을 마쳤지만 기준 점수 이상인 물체가 없습니다. 기준 점수를 낮춰 보세요.",
974
+ shown > 0 ? "ok" : "warn"
975
+ );
976
 
977
+ } catch (error) {
978
+ console.error(error);
979
+ setStatus(`분석에 실패했습니다: ${error.message}`, "error");
980
+ } finally {
981
+ setBusy(false);
982
+ }
983
+ }
984
 
 
 
985
 
986
+ // =====================================================
987
+ // 그리기
988
+ // =====================================================
989
 
990
+ function renderAll() {
991
+ const visible = visibleDetections();
992
+ const ranks = closenessRanks(visible);
993
+ drawCanvas(visible, ranks);
994
+ renderSummary(visible);
995
+ renderTable(visible, ranks);
996
+ }
997
 
998
+ function drawCanvas(visible, ranks) {
999
+ if (!state.srcCanvas) return;
1000
+
1001
+ const { W, H } = state;
1002
+ const view = currentView();
1003
+
1004
+ els.canvas.width = W;
1005
+ els.canvas.height = H;
1006
+ ctx.clearRect(0, 0, W, H);
1007
+
1008
+ if (view === "depth" && state.depthCanvas) {
1009
+ ctx.drawImage(state.depthCanvas, 0, 0, W, H);
1010
+ } else {
1011
+ ctx.drawImage(state.srcCanvas, 0, 0);
1012
+ if (view === "blend" && state.depthCanvas) {
1013
+ ctx.globalAlpha = 0.6;
1014
+ ctx.drawImage(state.depthCanvas, 0, 0, W, H);
1015
+ ctx.globalAlpha = 1;
1016
+ }
1017
+ }
1018
 
1019
+ // 캔버스가 화면에 몇 배로 줄어 보이는지 계산해서,
1020
+ // 화면에서 볼 때 글자가 최소 12px, 선이 최소 2px가 되도록 맞춤
1021
+ els.placeholder.style.display = "none";
1022
+ els.canvas.style.display = "block";
1023
+ const shownWidth = els.canvas.getBoundingClientRect().width || W;
1024
+ const scale = shownWidth / W; // 1보다 작으면 축소되어 보이는 중
1025
+ const lineWidth = Math.max(W / 320, 2 / scale);
1026
+ const fontSize = Math.round(Math.max(W / 48, 12 / scale));
1027
+ const compact = shownWidth < 520; // 좁은 화면은 이름표를 짧게
1028
+ ctx.font = `600 ${fontSize}px "Pretendard Variable", "Malgun Gothic", sans-serif`;
1029
+ ctx.textBaseline = "middle";
1030
+
1031
+ // 먼 물체부터 그려서 가까운 물체의 이름표가 위에 오게 함
1032
+ const order = visible.slice().sort((a, b) => (b.stats ? ranks.get(b.id) : 0) - (a.stats ? ranks.get(a.id) : 0));
1033
+
1034
+ for (const d of order) {
1035
+ const x = clamp(d.box.xmin, 0, W);
1036
+ const y = clamp(d.box.ymin, 0, H);
1037
+ const w = clamp(d.box.xmax, 0, W) - x;
1038
+ const h = clamp(d.box.ymax, 0, H) - y;
1039
+
1040
+ const rgb = d.stats ? colorFor(d.stats.closeness) : [255, 51, 102];
1041
+ const color = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
1042
+
1043
+ // 흰 테두리를 먼저 그려 깊이맵 위에서도 상자가 보이게 함
1044
+ ctx.lineWidth = lineWidth + 3;
1045
+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
1046
+ ctx.strokeRect(x, y, w, h);
1047
+ ctx.lineWidth = lineWidth;
1048
+ ctx.strokeStyle = color;
1049
+ ctx.strokeRect(x, y, w, h);
1050
+
1051
+ // 이름표
1052
+ let text = compact
1053
+ ? `#${d.id}`
1054
+ : `#${d.id} ${labelKo(d.label)} ${(d.score * 100).toFixed(0)}%`;
1055
+ if (d.stats) text += ` 근접 ${d.stats.closeness.toFixed(0)}`;
1056
+
1057
+ const padX = Math.round(fontSize * 0.45);
1058
+ const tagH = Math.round(fontSize * 1.5);
1059
+ const tagW = ctx.measureText(text).width + padX * 2;
1060
+ const tagX = clamp(x, 0, Math.max(0, W - tagW));
1061
+ const tagY = y - tagH >= 0 ? y - tagH : y; // 위에 자리가 없으면 상자 안쪽에
1062
+
1063
+ ctx.fillStyle = color;
1064
+ ctx.fillRect(tagX, tagY, tagW, tagH);
1065
+
1066
+ const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
1067
+ ctx.fillStyle = luminance > 0.6 ? "#18202e" : "#ffffff";
1068
+ ctx.fillText(text, tagX + padX, tagY + tagH / 2);
1069
+ }
1070
+ }
1071
 
1072
+ function renderSummary(visible) {
1073
+ if (!state.raw) { els.summary.innerHTML = ""; return; }
1074
 
1075
+ const counts = new Map();
1076
+ for (const d of visible) counts.set(d.label, (counts.get(d.label) ?? 0) + 1);
1077
 
1078
+ const items = [...counts.entries()]
1079
+ .sort((a, b) => b[1] - a[1])
1080
+ .map(([label, n]) => `<li>${esc(labelKo(label))} ${n}개</li>`);
 
 
 
1081
 
1082
+ els.summary.innerHTML = `<li class="total">전체 ${visible.length}개</li>${items.join("")}`;
1083
+ }
1084
 
1085
+ function renderTable(visible, ranks) {
1086
+ els.rows.innerHTML = "";
 
1087
 
1088
+ if (!state.raw) {
1089
+ els.tableEmpty.textContent = "아직 분석한 사진이 없습니다.";
1090
+ els.tableEmpty.style.display = "block";
1091
+ return;
1092
+ }
1093
+ if (visible.length === 0) {
1094
+ els.tableEmpty.textContent = "기준 점수 이상인 물체가 없습니다. 기준 점수를 낮춰 보세요.";
1095
+ els.tableEmpty.style.display = "block";
1096
+ return;
1097
+ }
1098
+ els.tableEmpty.style.display = "none";
1099
+
1100
+ const fragment = document.createDocumentFragment();
1101
+
1102
+ for (const d of visible) {
1103
+ const tr = document.createElement("tr");
1104
+ const s = d.stats;
1105
+ const rgb = s ? colorFor(s.closeness) : null;
1106
+ const warns = warningsFor(s);
1107
+
1108
+ tr.innerHTML = `
1109
+ <td>#${d.id}</td>
1110
+ <td>${esc(labelFull(d.label))}</td>
1111
+ <td class="num">${(d.score * 100).toFixed(1)}%</td>
1112
+ <td class="num">${s ? formatValue(s.median) : "—"}</td>
1113
+ <td>${s ? `<span class="closeness"><i style="background:rgb(${rgb.join(",")})"></i>${s.closeness.toFixed(0)}</span>` : "—"}</td>
1114
+ <td class="num">${s ? ranks.get(d.id) : "—"}</td>
1115
+ <td class="flag">${warns.length ? esc(warns.join(", ")) : ""}</td>
1116
+ <td></td>`;
1117
+
1118
+ const input = document.createElement("input");
1119
+ input.type = "number";
1120
+ input.className = "dist";
1121
+ input.min = "1";
1122
+ input.step = "any";
1123
+ input.inputMode = "decimal";
1124
+ input.placeholder = "cm";
1125
+ input.setAttribute("aria-label", `#${d.id} ${labelKo(d.label)} 실측 거리(cm)`);
1126
+ input.dataset.id = String(d.id);
1127
+ input.disabled = !s;
1128
+ if (state.distances.has(d.id)) input.value = String(state.distances.get(d.id));
1129
+
1130
+ input.addEventListener("input", () => {
1131
+ const v = Number(input.value);
1132
+ if (input.value !== "" && Number.isFinite(v) && v > 0) state.distances.set(d.id, v);
1133
+ else state.distances.delete(d.id);
1134
+ });
1135
+
1136
+ tr.lastElementChild.appendChild(input);
1137
+ fragment.appendChild(tr);
1138
+ }
1139
 
1140
+ els.rows.appendChild(fragment);
1141
+ }
1142
 
 
 
1143
 
1144
+ // =====================================================
1145
+ // 실측 비교 실험
1146
+ // =====================================================
1147
 
1148
+ function resetExperiment(message = "") {
1149
+ state.loo = new Map();
1150
+ state.plot = null;
1151
+ els.expOut.innerHTML = message ? `<p class="empty">${esc(message)}</p>` : "";
1152
+ }
1153
 
1154
+ function runExperiment() {
1155
+ if (!state.depth) {
1156
+ resetExperiment("깊이 추정 결과가 없어 비교할 수 없습니다.");
1157
+ return;
1158
+ }
1159
 
1160
+ const points = visibleDetections()
1161
+ .filter(d => d.stats && state.distances.has(d.id))
1162
+ .map(d => ({ id: d.id, label: d.label, d: state.distances.get(d.id), v: d.stats.median }));
1163
 
1164
+ if (points.length < 3) {
1165
+ resetExperiment(`실측 거리를 3개 이상 입력하세요. 지금 ${points.length}개 입력됐습니다.`);
1166
+ return;
1167
+ }
1168
 
1169
+ const distinct = new Set(points.map(p => p.d));
1170
+ if (distinct.size < 2) {
1171
+ resetExperiment("실측 거리가 모두 같습니다. 서로 다른 거리에 놓인 물체를 입력하세요.");
1172
+ return;
1173
+ }
 
1174
 
1175
+ const { concordant, total } = pairwiseConcordance(points);
1176
+ const vs = points.map(p => p.v);
1177
+ const rDist = pearson(vs, points.map(p => p.d));
1178
+ const rInv = pearson(vs, points.map(p => inverseX(p.d)));
1179
+ const fit = linearFit(points.map(p => inverseX(p.d)), vs);
1180
+ const loo = points.length >= 4 ? leaveOneOut(points) : null;
1181
 
1182
+ state.loo = new Map((loo ?? []).map(r => [r.id, r]));
 
1183
 
1184
+ // ---------- 해석 문장 ----------
1185
+ const notes = [];
1186
+ const orderPct = total > 0 ? (concordant / total) * 100 : 0;
1187
 
1188
+ notes.push(concordant === total
1189
+ ? "가까운 순서는 모든 쌍에서 맞았습니다."
1190
+ : `가까운 순서가 뒤바뀐 쌍이 ${total - concordant}개 있습니다. 표의 '참고' 칸과 사진의 조명·재질을 확인하세요.`);
 
 
1191
 
1192
+ if (rDist !== null && rInv !== null) {
1193
+ notes.push(Math.abs(rInv) > Math.abs(rDist)
1194
+ ? "깊이값은 거리 자체보다 거리의 역수(1/거리)와 더 직선 관계입니다. 가까운 곳에서는 값 차이가 크게 벌어지고 먼 곳에서는 좁아집니다."
1195
+ : "이 측정에서는 깊이값이 거리의 역수보다 거리 자체와 더 직선 관계로 나왔습니다. 측정 개수를 늘려 다시 확인해 볼 만합니다.");
1196
+ }
1197
 
1198
+ notes.push("깊이값은 사진마다 배율과 0점이 달라지는 상대값입니다. cm로 바꾸려면 같은 사진 안의 기준 물체로 보정해야 하고, 다른 사진끼리는 비교할 수 없습니다.");
1199
 
1200
+ let looHtml = "";
1201
+ if (loo) {
1202
+ const errs = loo.map(r => r.err).filter(e => e !== null);
1203
+ const failed = loo.length - errs.length;
1204
+ const mean = errs.length ? errs.reduce((s, e) => s + e, 0) / errs.length : null;
1205
+ const max = errs.length ? Math.max(...errs) : null;
1206
 
1207
+ notes.push(
1208
+ `하나를 빼고 나머지로 보정식을 만든 뒤 빠진 물체의 거리를 맞혀 본 결과, ` +
1209
+ (mean !== null ? `평균 오차 ${mean.toFixed(1)}%, 최대 오차 ${max.toFixed(1)}%입니다.` : "모두 추정에 실패했습니다.") +
1210
+ (failed ? ` ${failed}개는 보���식으로 거리가 계산되지 않았습니다(추정 불가).` : "")
1211
+ );
1212
 
1213
+ looHtml = `
1214
+ <div class="table-wrap">
1215
+ <table>
1216
+ <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>
1217
+ <tbody>
1218
+ ${loo.map(r => {
1219
+ const p = points.find(q => q.id === r.id);
1220
+ return `<tr>
1221
+ <td>#${r.id}</td>
1222
+ <td>${esc(labelKo(p.label))}</td>
1223
+ <td class="num">${formatValue(r.v)}</td>
1224
+ <td class="num">${r.d}</td>
1225
+ <td class="num">${r.pred === null ? "추정 불가" : r.pred.toFixed(1)}</td>
1226
+ <td class="num">${r.err === null ? "—" : r.err.toFixed(1) + "%"}</td>
1227
+ </tr>`;
1228
+ }).join("")}
1229
+ </tbody>
1230
+ </table>
1231
+ </div>`;
1232
+ } else {
1233
+ notes.push("실측 거리를 4개 이상 입력하면 '하나 빼고 맞히기'로 거리 추정 오차도 계산합니다.");
1234
+ }
1235
 
1236
+ els.expOut.innerHTML = `
1237
+ <div class="exp-grid">
1238
+ <div>
1239
+ <dl class="stat-list">
1240
+ <dt>비교한 물체</dt><dd>${points.length}개</dd>
1241
+ <dt>순서 일치</dt><dd>${concordant} / ${total}쌍 (${orderPct.toFixed(0)}%)</dd>
1242
+ <dt>상관계수: 거리</dt><dd>${rDist === null ? "—" : rDist.toFixed(3)}</dd>
1243
+ <dt>상관계수: 1/거리</dt><dd>${rInv === null ? "—" : rInv.toFixed(3)}</dd>
1244
+ </dl>
1245
+ <ul class="notes">${notes.map(n => `<li>${esc(n)}</li>`).join("")}</ul>
1246
+ </div>
1247
+ <div>
1248
+ <canvas id="plot" role="img" aria-label="1/실측 거리와 깊이값의 산점도"></canvas>
1249
+ <p class="hint">점: 물체 하나. 선: 모든 점으로 만든 보정식(깊이값 = a × 1/거리 + b).</p>
1250
+ </div>
1251
+ </div>
1252
+ ${looHtml}`;
1253
+
1254
+ state.plot = { points, fit };
1255
+ drawPlot($("plot"), points, fit);
1256
+ }
1257
 
1258
+ function drawPlot(canvas, points, fit) {
1259
+ const dpr = window.devicePixelRatio || 1;
1260
+ const cssW = canvas.clientWidth || 520;
1261
+ const cssH = Math.round(cssW * 0.62);
1262
+ canvas.width = Math.round(cssW * dpr);
1263
+ canvas.height = Math.round(cssH * dpr);
1264
+ canvas.style.height = `${cssH}px`;
1265
+
1266
+ const g = canvas.getContext("2d");
1267
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
1268
+ g.clearRect(0, 0, cssW, cssH);
1269
+
1270
+ const pad = { l: 70, r: 24, t: 16, b: 46 };
1271
+ const xs = points.map(p => inverseX(p.d));
1272
+ const ys = points.map(p => p.v);
1273
+
1274
+ let xMin = Math.min(...xs), xMax = Math.max(...xs);
1275
+ let yMin = Math.min(...ys), yMax = Math.max(...ys);
1276
+ const xPad = (xMax - xMin || 1) * 0.12;
1277
+ const yPad = (yMax - yMin || 1) * 0.15;
1278
+ xMin -= xPad; xMax += xPad; yMin -= yPad; yMax += yPad;
1279
+
1280
+ const px = x => pad.l + ((x - xMin) / (xMax - xMin)) * (cssW - pad.l - pad.r);
1281
+ const py = y => cssH - pad.b - ((y - yMin) / (yMax - yMin)) * (cssH - pad.t - pad.b);
1282
+
1283
+ // 축과 눈금
1284
+ g.strokeStyle = "#d8dde5";
1285
+ g.fillStyle = "#5b6474";
1286
+ g.lineWidth = 1;
1287
+ g.font = '12px "Pretendard Variable", "Malgun Gothic", sans-serif';
1288
+
1289
+ for (let i = 0; i <= 4; i++) {
1290
+ const xv = xMin + ((xMax - xMin) * i) / 4;
1291
+ const yv = yMin + ((yMax - yMin) * i) / 4;
1292
+
1293
+ g.beginPath(); g.moveTo(px(xv), pad.t); g.lineTo(px(xv), cssH - pad.b); g.stroke();
1294
+ g.beginPath(); g.moveTo(pad.l, py(yv)); g.lineTo(cssW - pad.r, py(yv)); g.stroke();
1295
+
1296
+ g.textAlign = "center"; g.textBaseline = "top";
1297
+ g.fillText(xv.toFixed(2), px(xv), cssH - pad.b + 6);
1298
+ g.textAlign = "right"; g.textBaseline = "middle";
1299
+ g.fillText(formatValue(yv), pad.l - 6, py(yv));
1300
+ }
1301
 
1302
+ g.textAlign = "center"; g.textBaseline = "bottom";
1303
+ g.fillText("1 / 실측 거리 (1/m)", pad.l + (cssW - pad.l - pad.r) / 2, cssH - 4);
1304
+
1305
+ g.save();
1306
+ g.translate(12, pad.t + (cssH - pad.t - pad.b) / 2);
1307
+ g.rotate(-Math.PI / 2);
1308
+ g.textBaseline = "middle";
1309
+ g.fillText("깊이값", 0, 0);
1310
+ g.restore();
1311
+
1312
+ // 보정식 직선
1313
+ if (fit) {
1314
+ g.strokeStyle = "#f89540";
1315
+ g.lineWidth = 2;
1316
+ g.beginPath();
1317
+ g.moveTo(px(xMin), py(fit.a * xMin + fit.b));
1318
+ g.lineTo(px(xMax), py(fit.a * xMax + fit.b));
1319
+ g.stroke();
1320
+ }
1321
 
1322
+ // 점
1323
+ g.fillStyle = "#5b1a9e";
1324
+ g.textAlign = "left";
1325
+ g.textBaseline = "bottom";
1326
+ for (const p of points) {
1327
+ const x = px(inverseX(p.d));
1328
+ const y = py(p.v);
1329
+ g.beginPath(); g.arc(x, y, 5, 0, Math.PI * 2); g.fill();
1330
+ g.fillText(`#${p.id}`, x + 7, y - 3);
1331
+ }
1332
+ }
1333
 
 
 
1334
 
1335
+ // =====================================================
1336
+ // 처리 시간
1337
+ // =====================================================
1338
 
1339
+ function renderTiming() {
1340
+ const t = state.timing;
1341
+ if (!t) return;
1342
+
1343
+ const rows = [
1344
+ ["사진 준비", t.prep],
1345
+ ["물체 탐지", t.detect],
1346
+ ["깊이 추정", t.depth],
1347
+ ["후처리·그리기", t.post]
1348
+ ];
1349
+ const max = Math.max(...rows.map(r => r[1] ?? 0)) || 1;
1350
+ const total = rows.reduce((s, r) => s + (r[1] ?? 0), 0);
1351
+
1352
+ els.timing.innerHTML = `
1353
+ <div class="bars">
1354
+ ${rows.map(([name, ms]) => `
1355
+ <div class="bar-row">
1356
+ <span>${name}</span>
1357
+ <div class="bar"><i style="width:${ms === null ? 0 : (ms / max) * 100}%"></i></div>
1358
+ <span class="ms">${ms === null ? "건너뜀" : `${Math.round(ms)} ms`}</span>
1359
+ </div>`).join("")}
1360
+ </div>
1361
+ <p class="hint" style="margin:0">합계 ${Math.round(total)} ms. 분석용 사진 크기 ${state.W}×${state.H}px.</p>`;
1362
+ }
1363
 
1364
+ async function runBenchmark() {
1365
+ if (!state.raw) return;
1366
+ setBusy(true);
1367
 
1368
+ const det = [];
1369
+ const dep = [];
1370
 
1371
+ try {
1372
+ for (let i = 0; i < BENCH_RUNS; i++) {
1373
+ setStatus(`속도를 측정하는 중입니다 (${i + 1}/${BENCH_RUNS}).`);
1374
 
1375
+ let start = performance.now();
1376
+ await detector(state.raw, { threshold: BASE_THRESHOLD });
1377
+ det.push(performance.now() - start);
1378
 
1379
+ if (depthEstimator) {
1380
+ start = performance.now();
1381
+ await depthEstimator(state.raw);
1382
+ dep.push(performance.now() - start);
1383
+ }
1384
+ }
1385
+
1386
+ state.bench = { det, dep };
1387
+
1388
+ const row = (name, arr) => arr.length
1389
+ ? `<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>`
1390
+ : "";
1391
+
1392
+ const perFrame = median(det) + (dep.length ? median(dep) : 0);
1393
+
1394
+ els.benchOut.innerHTML = `
1395
+ <div class="table-wrap" style="margin-top:12px">
1396
+ <table>
1397
+ <thead><tr><th>단계</th><th class="num">중앙값</th><th class="num">최소</th><th class="num">최대</th></tr></thead>
1398
+ <tbody>${row("물체 탐지", det)}${row("깊이 추정", dep)}</tbody>
1399
+ </table>
1400
+ </div>
1401
+ <p class="hint">${perFrame >= 1
1402
+ ? `한 장 처리에 약 ${Math.round(perFrame)} ms가 걸립니다. 실시간 영상이라면 초당 약 ${(1000 / perFrame).toFixed(1)}장을 처리할 수 있는 속도입니다.`
1403
+ : "한 장 처리 시간이 1 ms보다 짧아 초당 처리 장수를 계산하지 않았습니다."}</p>`;
1404
+
1405
+ setStatus("속도 측정을 마쳤습니다.", "ok");
1406
+ } catch (error) {
1407
+ console.error(error);
1408
+ setStatus(`속도 측정에 실패했습니다: ${error.message}`, "error");
1409
+ } finally {
1410
+ setBusy(false);
1411
+ }
1412
+ }
1413
 
 
 
1414
 
1415
+ // =====================================================
1416
+ // CSV 저장 (엑셀에서 한글이 깨지지 않도록 BOM 추가)
1417
+ // =====================================================
1418
 
1419
+ function downloadCsv() {
1420
+ if (!state.raw) return;
1421
+
1422
+ const visible = visibleDetections();
1423
+ const ranks = closenessRanks(visible);
1424
+ const lines = [];
1425
+
1426
+ lines.push(["파일", state.fileName]);
1427
+ lines.push(["분석용 사진 크기", `${state.W}x${state.H}`]);
1428
+ lines.push(["물체 탐지 모델", DETECT_MODEL]);
1429
+ lines.push(["깊이 추정 모델", depthModelName ?? "사용 안 함"]);
1430
+ lines.push(["기준 점수", currentThreshold().toFixed(2)]);
1431
+ lines.push([]);
1432
+ lines.push(["번호", "물체", "영문", "AI 확신도(%)", "깊이값(중앙값)", "근접도", "가까운 순위", "참고", "실측 거리(cm)", "추정 거리(cm)", "오차(%)"]);
1433
+
1434
+ for (const d of visible) {
1435
+ const s = d.stats;
1436
+ const loo = state.loo.get(d.id);
1437
+ lines.push([
1438
+ d.id,
1439
+ labelKo(d.label),
1440
+ d.label,
1441
+ (d.score * 100).toFixed(1),
1442
+ s ? s.median.toFixed(4) : "",
1443
+ s ? s.closeness.toFixed(1) : "",
1444
+ s ? ranks.get(d.id) : "",
1445
+ warningsFor(s).join(" / "),
1446
+ state.distances.get(d.id) ?? "",
1447
+ loo?.pred != null ? loo.pred.toFixed(1) : (loo ? "추정 불가" : ""),
1448
+ loo?.err != null ? loo.err.toFixed(1) : ""
1449
+ ]);
1450
+ }
1451
 
1452
+ if (state.timing) {
1453
+ lines.push([]);
1454
+ lines.push(["단계", "처리 시간(ms)"]);
1455
+ lines.push(["사진 준비", Math.round(state.timing.prep)]);
1456
+ lines.push(["물체 탐지", Math.round(state.timing.detect)]);
1457
+ lines.push(["깊이 추정", state.timing.depth === null ? "" : Math.round(state.timing.depth)]);
1458
+ lines.push(["후처리·그리기", Math.round(state.timing.post)]);
1459
+ }
1460
 
1461
+ if (state.bench) {
1462
+ lines.push([]);
1463
+ lines.push(["반복 측정", "회차별(ms)"]);
1464
+ lines.push(["물체 탐지", ...state.bench.det.map(Math.round)]);
1465
+ if (state.bench.dep.length) lines.push(["깊이 추정", ...state.bench.dep.map(Math.round)]);
1466
+ }
1467
 
1468
+ const csv = "\uFEFF" + lines.map(r => r.map(csvCell).join(",")).join("\r\n");
1469
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
1470
+ const url = URL.createObjectURL(blob);
1471
+ const a = document.createElement("a");
1472
+ a.href = url;
1473
+ // 일부 브라우저는 한글 파일명을 무시하고 'download'로 저장하므로 영문·숫자만 사용
1474
+ const base = state.fileName.replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9_-]/g, "");
1475
+ a.download = `depth-result_${base || "photo"}.csv`;
1476
+ document.body.appendChild(a);
1477
+ a.click();
1478
+ a.remove();
1479
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1480
+ }
1481
 
 
 
 
1482
 
1483
+ // =====================================================
1484
+ // 이벤트 연결 (모델을 불러오는 동안에도 조작부는 동작)
1485
+ // =====================================================
1486
 
1487
+ els.threshold.addEventListener("input", () => {
1488
+ els.thresholdValue.textContent = currentThreshold().toFixed(2);
1489
+ if (state.raw) {
1490
+ renderAll();
1491
+ if (els.expOut.innerHTML) resetExperiment("기준 점수가 바뀌었습니다. 실측 비교를 다시 계산하세요.");
1492
+ }
1493
+ });
1494
 
1495
+ for (const radio of els.viewRadios) {
1496
+ radio.addEventListener("change", () => {
1497
+ if (state.raw) drawCanvas(visibleDetections(), closenessRanks(visibleDetections()));
1498
+ });
1499
+ }
1500
 
1501
+ // 창 크기가 바뀌면 이름표 크기와 그래프를 다시 맞춤
1502
+ let resizeTimer = null;
1503
+ window.addEventListener("resize", () => {
1504
+ clearTimeout(resizeTimer);
1505
+ resizeTimer = setTimeout(() => {
1506
+ if (state.raw) drawCanvas(visibleDetections(), closenessRanks(visibleDetections()));
1507
+ const plot = $("plot");
1508
+ if (state.plot && plot) drawPlot(plot, state.plot.points, state.plot.fit);
1509
+ }, 150);
1510
+ });
1511
+
1512
+ els.run.addEventListener("click", analyze);
1513
+ els.exp.addEventListener("click", runExperiment);
1514
+ els.bench.addEventListener("click", runBenchmark);
1515
+ els.csv.addEventListener("click", downloadCsv);
1516
+
1517
+ els.file.addEventListener("change", () => {
1518
+ if (els.file.files[0] && detector !== null) {
1519
+ setStatus(`'${els.file.files[0].name}'을(를) 골랐습니다. 분석하기를 누르세요.`);
1520
+ }
1521
+ });
1522
 
1523
+ loadModels();
1524
 
1525
  </script>
1526
 
1527
  </body>
1528
+ </html>