icekun commited on
Commit
e2d4bb5
·
verified ·
1 Parent(s): e5e203c

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +465 -124
index.html CHANGED
@@ -3,139 +3,480 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>TinyModel MobileNetV4 Predictor (Gradio Lite)</title>
7
-
8
- <!-- Gradio Lite -->
9
- <script type="module" src="https://cdn.jsdelivr.net/npm/@gradio/lite/dist/lite.js"></script>
10
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gradio/lite/dist/lite.css" />
11
-
12
  <!-- ONNX Runtime Web -->
13
  <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
14
-
15
  <style>
16
- body {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  margin: 0;
18
- padding: 20px;
19
- background-color: #f8fafc;
20
- font-family: sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
22
  </style>
23
  </head>
24
  <body>
25
 
26
- <!-- py-packages にバージョン範囲制約を直接追加 -->
27
- <gradio-lite py-packages="pillow, numpy, filelock<3.16.0, huggingface-hub<1.0,>=0.33.5">
28
- <gradio-file name="app.py">
29
- import gradio as gr
30
- import numpy as np
31
- from PIL import Image
32
- import js
33
-
34
- MODEL_FILES = [
35
- "best_model.onnx",
36
- "model_epoch_1_acc_0.9849.onnx",
37
- "model_epoch_2_acc_0.9885.onnx",
38
- "model_epoch_3_acc_0.9865.onnx",
39
- "model_epoch_4_acc_0.9874.onnx",
40
- "model_epoch_5_acc_0.9877.onnx",
41
- "model_epoch_6_acc_0.9901.onnx",
42
- "model_epoch_7_acc_0.9908.onnx",
43
- "model_epoch_8_acc_0.9912.onnx",
44
- "model_epoch_9_acc_0.9909.onnx",
45
- "model_epoch_10_acc_0.9411.onnx",
46
- "model_epoch_11_acc_0.9929.onnx",
47
- "model_epoch_12_acc_0.9922.onnx",
48
- "model_epoch_13_acc_0.9918.onnx",
49
- "model_epoch_14_acc_0.9922.onnx",
50
- "model_epoch_15_acc_0.9925.onnx",
51
- "model_epoch_16_acc_0.9906.onnx",
52
- "model_epoch_17_acc_0.9908.onnx",
53
- "model_epoch_18_acc_0.9926.onnx",
54
- "model_epoch_19_acc_0.9916.onnx",
55
- "model_epoch_20_acc_0.9915.onnx"
56
- ]
57
-
58
- CLASS_NAMES = ["need", "trash"]
59
-
60
- current_session = None
61
- current_model_name = None
62
-
63
- async def get_session(model_name):
64
- global current_session, current_model_name
65
- if current_session is None or current_model_name != model_name:
66
- promise = js.ort.InferenceSession.create(f"./{model_name}")
67
- current_session = await promise
68
- current_model_name = model_name
69
- return current_session
70
-
71
- async def predict(image, model_name):
72
- if image is None:
73
- return "画像をアップロードしてください。"
74
-
75
- try:
76
- img = Image.fromarray(image).convert("RGB").resize((224, 224))
77
- img_np = np.array(img, dtype=np.float32) / 255.0
78
-
79
- mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
80
- std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
81
- img_np = (img_np - mean) / std
82
-
83
- img_np = img_np.transpose(2, 0, 1)
84
- img_np = np.expand_dims(img_np, axis=0)
85
-
86
- flat_data = img_np.flatten().tolist()
87
- js_data = js.Float32Array.new(flat_data)
88
- js_shape = js.Array.new(1, 3, 224, 224)
89
- input_tensor = js.ort.Tensor.new('float32', js_data, js_shape)
90
-
91
- session = await get_session(model_name)
92
- input_name = session.inputNames[0]
93
- output_name = session.outputNames[0]
94
-
95
- feeds = js.Object.new()
96
- js.Reflect.set(feeds, input_name, input_tensor)
97
-
98
- results = await session.run(feeds)
99
- output_data = js.Reflect.get(results, output_name).data
100
-
101
- out_list = [float(output_data[0]), float(output_data[1])]
102
-
103
- exp_vals = np.exp(out_list - np.max(out_list))
104
- probs = exp_vals / np.sum(exp_vals)
105
-
106
- return {
107
- CLASS_NAMES[0]: float(probs[0]),
108
- CLASS_NAMES[1]: float(probs[1])
109
- }
110
- except Exception as e:
111
- return f"エラー: {str(e)}"
112
-
113
- with gr.Blocks(title="TinyModel Predictor") as demo:
114
- gr.Markdown("## 🗑️ TinyModel (MobileNetV4) 判定 (Gradio Lite)")
115
- gr.Markdown("Wasm(Pyodide)技術を使用し、サーバーなし・ブラウザのみで判定を行うGradioアプリです。")
116
-
117
- with gr.Row():
118
- with gr.Column():
119
- input_image = gr.Image(label="入力画像")
120
- model_selector = gr.Dropdown(
121
- choices=MODEL_FILES,
122
- value=MODEL_FILES[0],
123
- label="使用するモデル重み (.onnx)"
124
- )
125
- submit_btn = gr.Button("判定実行", variant="primary")
126
-
127
- with gr.Column():
128
- output_label = gr.Label(num_top_classes=2, label="判定結果 (確率)")
129
-
130
- submit_btn.click(
131
- fn=predict,
132
- inputs=[input_image, model_selector],
133
- outputs=output_label
134
- )
135
-
136
- demo.launch()
137
- </gradio-file>
138
- </gradio-lite>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
  </body>
141
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TinyModel MobileNetV4 Predictor</title>
 
 
 
 
 
7
  <!-- ONNX Runtime Web -->
8
  <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
 
9
  <style>
10
+ :root {
11
+ --orange-primary: #ff7c00;
12
+ --orange-hover: #e56f00;
13
+ --bg-main: #f9fafb;
14
+ --block-bg: #ffffff;
15
+ --border-color: #e5e7eb;
16
+ --text-title: #111827;
17
+ --text-body: #374151;
18
+ --text-muted: #6b7280;
19
+ --radius-block: 10px;
20
+ --radius-input: 6px;
21
+ }
22
+
23
+ * {
24
+ box-sizing: border-box;
25
  margin: 0;
26
+ padding: 0;
27
+ }
28
+
29
+ body {
30
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
31
+ background-color: var(--bg-main);
32
+ color: var(--text-body);
33
+ padding: 2rem 1rem;
34
+ display: flex;
35
+ justify-content: center;
36
+ }
37
+
38
+ .gradio-container {
39
+ width: 100%;
40
+ max-width: 960px;
41
+ background: var(--bg-main);
42
+ }
43
+
44
+ .gradio-header {
45
+ margin-bottom: 1.5rem;
46
+ text-align: left;
47
+ }
48
+
49
+ .gradio-header h1 {
50
+ font-size: 1.75rem;
51
+ font-weight: 700;
52
+ color: var(--text-title);
53
+ margin-bottom: 0.5rem;
54
+ }
55
+
56
+ .gradio-header p {
57
+ color: var(--text-muted);
58
+ font-size: 0.95rem;
59
+ }
60
+
61
+ .gradio-layout {
62
+ display: grid;
63
+ grid-template-columns: 1fr;
64
+ gap: 1.5rem;
65
+ }
66
+
67
+ @media (min-width: 768px) {
68
+ .gradio-layout {
69
+ grid-template-columns: 1fr 1fr;
70
+ }
71
+ }
72
+
73
+ .gradio-column {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 1.25rem;
77
+ }
78
+
79
+ .gr-block {
80
+ background: var(--block-bg);
81
+ border: 1px solid var(--border-color);
82
+ border-radius: var(--radius-block);
83
+ padding: 1rem;
84
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
85
+ }
86
+
87
+ .gr-label {
88
+ font-size: 0.85rem;
89
+ font-weight: 600;
90
+ color: var(--text-muted);
91
+ margin-bottom: 0.5rem;
92
+ display: block;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.05em;
95
+ }
96
+
97
+ .gr-select {
98
+ width: 100%;
99
+ padding: 0.6rem 0.75rem;
100
+ border: 1px solid var(--border-color);
101
+ border-radius: var(--radius-input);
102
+ background-color: #fff;
103
+ font-size: 0.95rem;
104
+ color: var(--text-title);
105
+ outline: none;
106
+ cursor: pointer;
107
+ }
108
+
109
+ .gr-select:focus {
110
+ border-color: var(--orange-primary);
111
+ }
112
+
113
+ /* Gradio Image Upload Component */
114
+ .gr-image-upload {
115
+ border: 2px dashed var(--border-color);
116
+ border-radius: var(--radius-input);
117
+ padding: 2rem 1rem;
118
+ text-align: center;
119
+ background-color: #f9fafb;
120
+ cursor: pointer;
121
+ position: relative;
122
+ transition: all 0.2s;
123
+ min-height: 220px;
124
+ display: flex;
125
+ flex-direction: column;
126
+ align-items: center;
127
+ justify-content: center;
128
+ }
129
+
130
+ .gr-image-upload:hover {
131
+ border-color: var(--orange-primary);
132
+ background-color: #fff7ed;
133
+ }
134
+
135
+ .gr-image-upload input[type="file"] {
136
+ position: absolute;
137
+ width: 100%;
138
+ height: 100%;
139
+ top: 0;
140
+ left: 0;
141
+ opacity: 0;
142
+ cursor: pointer;
143
+ }
144
+
145
+ .upload-icon {
146
+ font-size: 2.5rem;
147
+ color: var(--text-muted);
148
+ margin-bottom: 0.5rem;
149
+ }
150
+
151
+ .upload-text {
152
+ font-size: 0.9rem;
153
+ color: var(--text-muted);
154
+ }
155
+
156
+ .preview-img {
157
+ max-width: 100%;
158
+ max-height: 260px;
159
+ border-radius: var(--radius-input);
160
+ display: none;
161
+ object-fit: contain;
162
+ }
163
+
164
+ /* Gradio Button */
165
+ .gr-button-primary {
166
+ width: 100%;
167
+ padding: 0.75rem;
168
+ background-color: var(--orange-primary);
169
+ color: #ffffff;
170
+ border: none;
171
+ border-radius: var(--radius-input);
172
+ font-size: 1rem;
173
+ font-weight: 600;
174
+ cursor: pointer;
175
+ transition: background-color 0.15s;
176
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
177
+ }
178
+
179
+ .gr-button-primary:hover:not(:disabled) {
180
+ background-color: var(--orange-hover);
181
+ }
182
+
183
+ .gr-button-primary:disabled {
184
+ background-color: #d1d5db;
185
+ cursor: not-allowed;
186
+ box-shadow: none;
187
+ }
188
+
189
+ /* Status Banner */
190
+ .status-banner {
191
+ font-size: 0.85rem;
192
+ padding: 0.5rem 0.75rem;
193
+ border-radius: var(--radius-input);
194
+ background-color: #f3f4f6;
195
+ color: var(--text-muted);
196
+ display: flex;
197
+ align-items: center;
198
+ gap: 0.5rem;
199
+ }
200
+
201
+ /* Gradio Label / Output Result Component */
202
+ .gr-label-output {
203
+ display: flex;
204
+ flex-direction: column;
205
+ gap: 0.75rem;
206
+ }
207
+
208
+ .top-class-box {
209
+ text-align: center;
210
+ padding: 1rem;
211
+ background-color: #fff7ed;
212
+ border: 1px solid #ffedd5;
213
+ border-radius: var(--radius-input);
214
+ margin-bottom: 0.5rem;
215
+ }
216
+
217
+ .top-class-name {
218
+ font-size: 1.5rem;
219
+ font-weight: 700;
220
+ color: var(--orange-primary);
221
+ }
222
+
223
+ .top-class-conf {
224
+ font-size: 0.9rem;
225
+ color: var(--text-muted);
226
+ }
227
+
228
+ .prob-row {
229
+ display: flex;
230
+ flex-direction: column;
231
+ gap: 0.25rem;
232
+ }
233
+
234
+ .prob-header {
235
+ display: flex;
236
+ justify-content: space-between;
237
+ font-size: 0.85rem;
238
+ font-weight: 600;
239
+ }
240
+
241
+ .prob-bar-bg {
242
+ height: 20px;
243
+ background-color: #f3f4f6;
244
+ border-radius: 4px;
245
+ overflow: hidden;
246
+ }
247
+
248
+ .prob-bar-fill {
249
+ height: 100%;
250
+ background-color: var(--orange-primary);
251
+ width: 0%;
252
+ transition: width 0.4s ease;
253
+ }
254
+
255
+ .prob-bar-fill.secondary {
256
+ background-color: #9ca3af;
257
+ }
258
+
259
+ .empty-output {
260
+ text-align: center;
261
+ color: var(--text-muted);
262
+ padding: 3rem 1rem;
263
+ font-size: 0.9rem;
264
  }
265
  </style>
266
  </head>
267
  <body>
268
 
269
+ <div class="gradio-container">
270
+ <div class="gradio-header">
271
+ <h1>🗑️ TinyModel (MobileNetV4) 判定</h1>
272
+ <p>画像をアップロードし、ドロップダウンで選択した .onnx モデルの判定結果(need / trash)を表示します。</p>
273
+ </div>
274
+
275
+ <div class="gradio-layout">
276
+ <!-- 左カラム:入力 -->
277
+ <div class="gradio-column">
278
+ <div class="gr-block">
279
+ <span class="gr-label">使用するモデル重み (.onnx)</span>
280
+ <select id="modelSelect" class="gr-select"></select>
281
+ </div>
282
+
283
+ <div class="gr-block">
284
+ <span class="gr-label">入力画像</span>
285
+ <div class="gr-image-upload" id="dropzone">
286
+ <input type="file" id="fileInput" accept="image/*">
287
+ <div id="uploadPrompt">
288
+ <div class="upload-icon">📁</div>
289
+ <div class="upload-text">クリックまたはドラッグ&ドロップで画像をアップロード</div>
290
+ </div>
291
+ <img id="previewImg" class="preview-img" alt="Preview">
292
+ </div>
293
+ </div>
294
+
295
+ <button id="submitBtn" class="gr-button-primary" disabled>Submit</button>
296
+
297
+ <div id="statusBanner" class="status-banner">
298
+ <span id="statusText">モデルをロード中...</span>
299
+ </div>
300
+ </div>
301
+
302
+ <!-- 右カラム:出力 -->
303
+ <div class="gradio-column">
304
+ <div class="gr-block" style="min-height: 100%;">
305
+ <span class="gr-label">判定結果 (確率)</span>
306
+
307
+ <div id="emptyOutput" class="empty-output">
308
+ 画像を送信すると判定結果がここに表示されます。
309
+ </div>
310
+
311
+ <div id="resultOutput" class="gr-label-output" style="display: none;">
312
+ <div class="top-class-box">
313
+ <div id="topClassName" class="top-class-name">need</div>
314
+ <div id="topClassConf" class="top-class-conf">信頼度: 99.8%</div>
315
+ </div>
316
+
317
+ <div class="prob-row">
318
+ <div class="prob-header">
319
+ <span>need</span>
320
+ <span id="probNeedText">0%</span>
321
+ </div>
322
+ <div class="prob-bar-bg">
323
+ <div id="probNeedBar" class="prob-bar-fill"></div>
324
+ </div>
325
+ </div>
326
+
327
+ <div class="prob-row">
328
+ <div class="prob-header">
329
+ <span>trash</span>
330
+ <span id="probTrashText">0%</span>
331
+ </div>
332
+ <div class="prob-bar-bg">
333
+ <div id="probTrashBar" class="prob-bar-fill secondary"></div>
334
+ </div>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </div>
340
+ </div>
341
+
342
+ <script>
343
+ const models = ["best_model.onnx", "model_epoch_1_acc_0.9849.onnx", "model_epoch_2_acc_0.9885.onnx", "model_epoch_3_acc_0.9865.onnx", "model_epoch_4_acc_0.9874.onnx", "model_epoch_5_acc_0.9877.onnx", "model_epoch_6_acc_0.9901.onnx", "model_epoch_7_acc_0.9908.onnx", "model_epoch_8_acc_0.9912.onnx", "model_epoch_9_acc_0.9909.onnx", "model_epoch_10_acc_0.9411.onnx", "model_epoch_11_acc_0.9929.onnx", "model_epoch_12_acc_0.9922.onnx", "model_epoch_13_acc_0.9918.onnx", "model_epoch_14_acc_0.9922.onnx", "model_epoch_15_acc_0.9925.onnx", "model_epoch_16_acc_0.9906.onnx", "model_epoch_17_acc_0.9908.onnx", "model_epoch_18_acc_0.9926.onnx", "model_epoch_19_acc_0.9916.onnx", "model_epoch_20_acc_0.9915.onnx"];
344
+ let session = null;
345
+ let isReady = false;
346
+
347
+ const modelSelect = document.getElementById('modelSelect');
348
+ const fileInput = document.getElementById('fileInput');
349
+ const previewImg = document.getElementById('previewImg');
350
+ const uploadPrompt = document.getElementById('uploadPrompt');
351
+ const submitBtn = document.getElementById('submitBtn');
352
+ const statusText = document.getElementById('statusText');
353
+ const emptyOutput = document.getElementById('emptyOutput');
354
+ const resultOutput = document.getElementById('resultOutput');
355
+
356
+ function initSelect() {
357
+ models.forEach(m => {
358
+ const opt = document.createElement('option');
359
+ opt.value = m;
360
+ opt.textContent = m;
361
+ modelSelect.appendChild(opt);
362
+ });
363
+ modelSelect.addEventListener('change', (e) => loadModel(e.target.value));
364
+ }
365
+
366
+ async function loadModel(modelName) {
367
+ isReady = false;
368
+ submitBtn.disabled = true;
369
+ statusText.innerText = "⏳ " + modelName + " をロード中...";
370
+
371
+ try {
372
+ if (session) {
373
+ await session.release();
374
+ session = null;
375
+ }
376
+ session = await ort.InferenceSession.create('./' + modelName);
377
+ isReady = true;
378
+ statusText.innerText = "✅ " + modelName + " の準備完了";
379
+
380
+ if (previewImg.src && previewImg.style.display !== 'none') {
381
+ submitBtn.disabled = false;
382
+ }
383
+ } catch (e) {
384
+ statusText.innerText = "❌ モデル読込エラー: " + e.message;
385
+ }
386
+ }
387
+
388
+ function handleFile(file) {
389
+ if (!file || !file.type.startsWith('image/')) return;
390
+ const reader = new FileReader();
391
+ reader.onload = (e) => {
392
+ previewImg.src = e.target.result;
393
+ previewImg.style.display = 'block';
394
+ uploadPrompt.style.display = 'none';
395
+ resultOutput.style.display = 'none';
396
+ emptyOutput.style.display = 'block';
397
+ if (isReady) submitBtn.disabled = false;
398
+ };
399
+ reader.readAsDataURL(file);
400
+ }
401
+
402
+ fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
403
+
404
+ async function preprocess(imgElement) {
405
+ const canvas = document.createElement('canvas');
406
+ canvas.width = 224;
407
+ canvas.height = 224;
408
+ const ctx = canvas.getContext('2d');
409
+ ctx.drawImage(imgElement, 0, 0, 224, 224);
410
+ const data = ctx.getImageData(0, 0, 224, 224).data;
411
+
412
+ const mean = [0.485, 0.456, 0.406];
413
+ const std = [0.229, 0.224, 0.225];
414
+ const floatData = new Float32Array(3 * 224 * 224);
415
+
416
+ for (let i = 0; i < 224 * 224; i++) {
417
+ const r = data[i * 4] / 255.0;
418
+ const g = data[i * 4 + 1] / 255.0;
419
+ const b = data[i * 4 + 2] / 255.0;
420
+
421
+ floatData[i] = (r - mean[0]) / std[0];
422
+ floatData[224 * 224 + i] = (g - mean[1]) / std[1];
423
+ floatData[2 * 224 * 224 + i] = (b - mean[2]) / std[2];
424
+ }
425
+
426
+ return new ort.Tensor('float32', floatData, [1, 3, 224, 224]);
427
+ }
428
+
429
+ submitBtn.addEventListener('click', async () => {
430
+ if (!isReady || !session || !previewImg.src) return;
431
+
432
+ submitBtn.disabled = true;
433
+ statusText.innerText = "⚡ 推論中...";
434
+
435
+ try {
436
+ const inputTensor = await preprocess(previewImg);
437
+ const feeds = {};
438
+ feeds[session.inputNames[0]] = inputTensor;
439
+
440
+ const start = performance.now();
441
+ const results = await session.run(feeds);
442
+ const duration = (performance.now() - start).toFixed(1);
443
+
444
+ const output = results[session.outputNames[0]].data;
445
+ const exp0 = Math.exp(output[0]);
446
+ const exp1 = Math.exp(output[1]);
447
+ const sum = exp0 + exp1;
448
+ const probNeed = (exp0 / sum) * 100;
449
+ const probTrash = (exp1 / sum) * 100;
450
+
451
+ // UI Render
452
+ emptyOutput.style.display = 'none';
453
+ resultOutput.style.display = 'flex';
454
+
455
+ const topClass = probNeed >= probTrash ? "need" : "trash";
456
+ const topConf = Math.max(probNeed, probTrash).toFixed(1);
457
+
458
+ document.getElementById('topClassName').innerText = topClass;
459
+ document.getElementById('topClassConf').innerText = "信頼度: " + topConf + "%";
460
+
461
+ document.getElementById('probNeedText').innerText = probNeed.toFixed(1) + "%";
462
+ document.getElementById('probNeedBar').style.width = probNeed + "%";
463
+
464
+ document.getElementById('probTrashText').innerText = probTrash.toFixed(1) + "%";
465
+ document.getElementById('probTrashBar').style.width = probTrash + "%";
466
+
467
+ statusText.innerText = "✅ 完了 (" + duration + "ms)";
468
+ } catch (e) {
469
+ statusText.innerText = "❌ エラー: " + e.message;
470
+ } finally {
471
+ submitBtn.disabled = false;
472
+ }
473
+ });
474
+
475
+ window.onload = () => {
476
+ initSelect();
477
+ if (models.length > 0) loadModel(models[0]);
478
+ };
479
+ </script>
480
 
481
  </body>
482
  </html>