Spaces:
Running
Running
Update index.html
Browse files- 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 |
-
<
|
|
|
|
|
|
|
| 8 |
|
| 9 |
<style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
body {
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
| 16 |
}
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
h1 {
|
| 19 |
-
font-size:
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
-
.
|
| 24 |
-
color: #5f6368;
|
| 25 |
-
margin-top: 0;
|
| 26 |
-
}
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
background: #f1f3f4;
|
| 33 |
border-radius: 6px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
-
.
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
cursor: pointer;
|
| 52 |
-
font-size: 15px;
|
| 53 |
}
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
border:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
border-radius: 6px;
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
border-radius: 6px;
|
| 71 |
-
|
| 72 |
-
line-height: 1.7;
|
| 73 |
}
|
| 74 |
|
| 75 |
-
.
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
</style>
|
| 79 |
</head>
|
| 80 |
|
| 81 |
<body>
|
| 82 |
|
| 83 |
-
<
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 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 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
-
const MODEL_NAME =
|
| 165 |
-
"Xenova/yolos-tiny";
|
| 166 |
|
| 167 |
// =====================================================
|
| 168 |
-
// 물체 이름 한글 표시
|
| 169 |
// =====================================================
|
| 170 |
-
|
| 171 |
const LABEL_KO = {
|
| 172 |
-
person: "사람",
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
|
|
|
| 205 |
// =====================================================
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
| 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 |
-
|
| 229 |
-
|
|
|
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
}
|
| 244 |
-
);
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
//
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
).toFixed(1);
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
|
|
|
|
|
|
| 278 |
|
| 279 |
-
} catch (error) {
|
| 280 |
|
| 281 |
-
|
| 282 |
-
`❌ 모델을 불러오지 못함 : ${error.message}`;
|
| 283 |
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
| 285 |
|
|
|
|
|
|
|
| 286 |
}
|
| 287 |
|
| 288 |
|
| 289 |
// =====================================================
|
| 290 |
-
//
|
| 291 |
// =====================================================
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
|
| 299 |
// =====================================================
|
| 300 |
-
//
|
| 301 |
// =====================================================
|
| 302 |
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
|
| 319 |
-
|
|
|
|
|
|
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
|
|
|
|
|
|
| 323 |
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
|
|
|
|
|
|
| 326 |
}
|
|
|
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
-
|
| 330 |
-
|
| 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 |
-
|
| 354 |
-
|
|
|
|
|
|
|
| 355 |
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
| 357 |
|
| 358 |
-
// ------------------------------------------------
|
| 359 |
-
// Canvas에 원본 이미지 표시
|
| 360 |
-
// ------------------------------------------------
|
| 361 |
|
| 362 |
-
|
| 363 |
-
|
|
|
|
| 364 |
|
| 365 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
| 367 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
);
|
| 375 |
|
|
|
|
|
|
|
|
|
|
| 376 |
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
| 384 |
-
|
| 385 |
-
|
| 386 |
|
|
|
|
| 387 |
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
|
| 392 |
-
|
| 393 |
-
|
|
|
|
| 394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
|
| 396 |
-
const output =
|
| 397 |
-
await detector(
|
| 398 |
-
imageURL,
|
| 399 |
-
{
|
| 400 |
-
threshold: score
|
| 401 |
-
}
|
| 402 |
-
);
|
| 403 |
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
| 415 |
|
| 416 |
-
|
|
|
|
| 417 |
|
|
|
|
|
|
|
| 418 |
|
| 419 |
-
// ------------------------------------------------
|
| 420 |
-
// 찾은 물체 하나씩 처리
|
| 421 |
-
// ------------------------------------------------
|
| 422 |
|
| 423 |
-
|
|
|
|
|
|
|
| 424 |
|
| 425 |
-
|
| 426 |
-
|
|
|
|
| 427 |
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
-
|
| 432 |
-
|
|
|
|
|
|
|
| 433 |
|
|
|
|
|
|
|
| 434 |
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
-
counts[label]++;
|
| 439 |
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
|
| 451 |
-
const x =
|
| 452 |
-
box.xmin;
|
| 453 |
|
| 454 |
-
|
| 455 |
-
|
|
|
|
| 456 |
|
| 457 |
-
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
|
| 460 |
-
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
|
| 464 |
-
|
|
|
|
| 465 |
|
| 466 |
-
|
| 467 |
-
|
| 468 |
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
width,
|
| 473 |
-
height
|
| 474 |
-
);
|
| 475 |
|
|
|
|
|
|
|
| 476 |
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
// ----------------------------------------------
|
| 480 |
|
| 481 |
-
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
|
|
|
|
|
|
|
| 484 |
|
| 485 |
-
ctx.font =
|
| 486 |
-
"20px Arial";
|
| 487 |
|
|
|
|
|
|
|
|
|
|
| 488 |
|
| 489 |
-
|
| 490 |
-
|
|
|
|
|
|
|
|
|
|
| 491 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
|
| 493 |
-
|
| 494 |
-
|
|
|
|
| 495 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
);
|
| 503 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
-
|
| 506 |
-
"white";
|
| 507 |
|
|
|
|
|
|
|
|
|
|
| 508 |
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
Math.max(20, y - 7)
|
| 513 |
-
);
|
| 514 |
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
|
|
|
|
| 517 |
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
|
|
|
|
|
|
|
|
|
| 521 |
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
-
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
|
| 538 |
-
resultText +=
|
| 539 |
-
`${getLabelName(label)} : ${count}개\n`;
|
| 540 |
|
| 541 |
-
|
|
|
|
|
|
|
| 542 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
|
| 544 |
-
|
| 545 |
-
|
|
|
|
| 546 |
|
|
|
|
|
|
|
| 547 |
|
| 548 |
-
|
| 549 |
-
|
|
|
|
| 550 |
|
| 551 |
-
|
|
|
|
|
|
|
| 552 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
|
| 554 |
-
statusBox.textContent =
|
| 555 |
-
`완료 · 분석 ${seconds}초 · 기준 점수 ${score.toFixed(2)} · (모델 불러오기 ${loadSeconds}초)`;
|
| 556 |
|
|
|
|
|
|
|
|
|
|
| 557 |
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
|
| 560 |
-
|
| 561 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
|
| 563 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
-
URL.revokeObjectURL(
|
| 568 |
-
imageURL
|
| 569 |
-
);
|
| 570 |
|
| 571 |
-
|
|
|
|
|
|
|
| 572 |
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
|
| 576 |
-
|
| 577 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|