vimdhayak commited on
Commit
4a2ab6a
·
verified ·
1 Parent(s): 69d8775

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +48 -6
  2. app.js +466 -0
  3. index.html +168 -17
  4. ms_se_efficientnet_b0.onnx +3 -0
  5. styles.css +348 -0
README.md CHANGED
@@ -1,10 +1,52 @@
1
  ---
2
- title: Garbage Classification
3
- emoji: 🐠
4
- colorFrom: blue
5
- colorTo: gray
6
  sdk: static
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MS-SE Waste Classifier
3
+ emoji: ♻️
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: static
7
+ app_file: index.html
8
+ fullWidth: true
9
+ header: mini
10
+ short_description: Browser waste classification with MS-SE-EfficientNet-B0
11
+ tags:
12
+ - computer-vision
13
+ - image-classification
14
+ - waste-classification
15
+ - efficientnet
16
+ - onnx
17
+ - browser-inference
18
  ---
19
 
20
+ # MS-SE Waste Classifier
21
+
22
+ A fully static Hugging Face Space for ten-class waste image classification. Inference runs locally in the visitor's browser through ONNX Runtime Web.
23
+
24
+ ## Upload these files to the Space root
25
+
26
+ ```text
27
+ README.md
28
+ index.html
29
+ styles.css
30
+ app.js
31
+ ms_se_efficientnet_b0.onnx
32
+ ```
33
+
34
+ Do not place any file inside a subfolder.
35
+
36
+ ## Model configuration
37
+
38
+ - Architecture: MS-SE-EfficientNet-B0
39
+ - Input: RGB image resized to 224 × 224
40
+ - Normalization: ImageNet mean `[0.485, 0.456, 0.406]` and standard deviation `[0.229, 0.224, 0.225]`
41
+ - Classes: battery, biological, cardboard, clothes, glass, metal, paper, plastic, shoes, trash
42
+ - Runtime: ONNX Runtime Web with WebAssembly
43
+
44
+ ## Local test
45
+
46
+ Run a local web server from this folder:
47
+
48
+ ```bash
49
+ python -m http.server 8000
50
+ ```
51
+
52
+ Then open `http://localhost:8000`.
app.js ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const APP = Object.freeze({
4
+ modelUrl: './ms_se_efficientnet_b0.onnx?v=ac619ed4',
5
+ ortVersion: '1.27.0',
6
+ imageSize: 224,
7
+ mean: [0.485, 0.456, 0.406],
8
+ std: [0.229, 0.224, 0.225],
9
+ labels: [
10
+ 'battery', 'biological', 'cardboard', 'clothes', 'glass',
11
+ 'metal', 'paper', 'plastic', 'shoes', 'trash',
12
+ ],
13
+ metadata: {
14
+ input: { width: 224, height: 224, channels: 3 },
15
+ complexity: { parameters: 4234159 },
16
+ },
17
+ });
18
+
19
+ const state = {
20
+ session: null,
21
+ labels: APP.labels,
22
+ metadata: APP.metadata,
23
+ bitmap: null,
24
+ objectUrl: null,
25
+ file: null,
26
+ modelReady: false,
27
+ predicting: false,
28
+ };
29
+
30
+ const els = {
31
+ runtimePill: document.querySelector('#runtimePill'),
32
+ runtimeText: document.querySelector('#runtimeText'),
33
+ statusDot: document.querySelector('#statusDot'),
34
+ statusCard: document.querySelector('.status-card'),
35
+ progressBar: document.querySelector('#progressBar'),
36
+ loadPercent: document.querySelector('#loadPercent'),
37
+ statusDetail: document.querySelector('#statusDetail'),
38
+ fileInput: document.querySelector('#fileInput'),
39
+ dropZone: document.querySelector('#dropZone'),
40
+ clearButton: document.querySelector('#clearButton'),
41
+ previewWrap: document.querySelector('#previewWrap'),
42
+ imagePreview: document.querySelector('#imagePreview'),
43
+ fileName: document.querySelector('#fileName'),
44
+ fileDetails: document.querySelector('#fileDetails'),
45
+ predictButton: document.querySelector('#predictButton'),
46
+ predictButtonText: document.querySelector('#predictButtonText'),
47
+ buttonSpinner: document.querySelector('#buttonSpinner'),
48
+ inputMessage: document.querySelector('#inputMessage'),
49
+ emptyResult: document.querySelector('#emptyResult'),
50
+ results: document.querySelector('#results'),
51
+ predictedClass: document.querySelector('#predictedClass'),
52
+ confidenceRing: document.querySelector('#confidenceRing'),
53
+ confidenceValue: document.querySelector('#confidenceValue'),
54
+ topThree: document.querySelector('#topThree'),
55
+ probabilityList: document.querySelector('#probabilityList'),
56
+ preprocessTime: document.querySelector('#preprocessTime'),
57
+ inferenceTime: document.querySelector('#inferenceTime'),
58
+ totalTime: document.querySelector('#totalTime'),
59
+ providerBadge: document.querySelector('#providerBadge'),
60
+ classChips: document.querySelector('#classChips'),
61
+ modelFacts: document.querySelector('#modelFacts'),
62
+ canvas: document.querySelector('#preprocessCanvas'),
63
+ };
64
+
65
+ function setProgress(percent, detail) {
66
+ const safePercent = Math.max(0, Math.min(100, Math.round(percent)));
67
+ els.progressBar.style.width = `${safePercent}%`;
68
+ els.loadPercent.textContent = `${safePercent}%`;
69
+ if (detail) els.statusDetail.textContent = detail;
70
+ }
71
+
72
+ function setRuntimeState(kind, text) {
73
+ els.runtimePill.classList.remove('ready', 'error');
74
+ if (kind) els.runtimePill.classList.add(kind);
75
+ els.runtimeText.textContent = text;
76
+ }
77
+
78
+ function setError(message, error) {
79
+ console.error(message, error || '');
80
+ state.modelReady = false;
81
+ els.statusCard.classList.add('error');
82
+ setRuntimeState('error', 'Runtime unavailable');
83
+ setProgress(100, message);
84
+ els.inputMessage.textContent = 'The model could not be initialized. Refresh the page or try another modern browser.';
85
+ els.inputMessage.classList.add('error');
86
+ updatePredictButton();
87
+ }
88
+
89
+ function titleCase(value) {
90
+ return String(value)
91
+ .replace(/[_-]+/g, ' ')
92
+ .replace(/\b\w/g, character => character.toUpperCase());
93
+ }
94
+
95
+ function formatBytes(bytes) {
96
+ if (!Number.isFinite(bytes) || bytes <= 0) return '';
97
+ const units = ['B', 'KB', 'MB', 'GB'];
98
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
99
+ return `${(bytes / (1024 ** index)).toFixed(index >= 2 ? 1 : 0)} ${units[index]}`;
100
+ }
101
+
102
+ function formatMilliseconds(value) {
103
+ if (!Number.isFinite(value)) return '—';
104
+ if (value < 10) return `${value.toFixed(2)} ms`;
105
+ if (value < 100) return `${value.toFixed(1)} ms`;
106
+ return `${Math.round(value)} ms`;
107
+ }
108
+
109
+ function updatePredictButton() {
110
+ const enabled = state.modelReady && Boolean(state.bitmap) && !state.predicting;
111
+ els.predictButton.disabled = !enabled;
112
+ els.clearButton.disabled = !state.bitmap && !state.file;
113
+ }
114
+
115
+ async function fetchBinaryWithProgress(url, onProgress) {
116
+ const response = await fetch(url, { cache: 'force-cache' });
117
+ if (!response.ok) throw new Error(`Model download failed: HTTP ${response.status}`);
118
+
119
+ const total = Number(response.headers.get('content-length')) || 0;
120
+ if (!response.body || !total) {
121
+ const buffer = await response.arrayBuffer();
122
+ onProgress(1, buffer.byteLength);
123
+ return new Uint8Array(buffer);
124
+ }
125
+
126
+ const reader = response.body.getReader();
127
+ const chunks = [];
128
+ let received = 0;
129
+
130
+ while (true) {
131
+ const { done, value } = await reader.read();
132
+ if (done) break;
133
+ chunks.push(value);
134
+ received += value.length;
135
+ onProgress(received / total, total);
136
+ }
137
+
138
+ const bytes = new Uint8Array(received);
139
+ let offset = 0;
140
+ for (const chunk of chunks) {
141
+ bytes.set(chunk, offset);
142
+ offset += chunk.length;
143
+ }
144
+ return bytes;
145
+ }
146
+
147
+ function renderClassChips() {
148
+ els.classChips.replaceChildren();
149
+ for (const label of state.labels) {
150
+ const chip = document.createElement('span');
151
+ chip.className = 'class-chip';
152
+ chip.textContent = label;
153
+ els.classChips.appendChild(chip);
154
+ }
155
+ }
156
+
157
+ function renderModelFacts() {
158
+ if (!state.metadata) return;
159
+ const facts = [
160
+ ['Input', `${state.metadata.input.width} × ${state.metadata.input.height} RGB`],
161
+ ['Classes', String(state.metadata.classes.length)],
162
+ ['Parameters', `${(state.metadata.complexity.parameters / 1e6).toFixed(3)}M`],
163
+ ['Runtime', 'WebAssembly'],
164
+ ];
165
+ els.modelFacts.replaceChildren();
166
+ for (const [term, value] of facts) {
167
+ const wrapper = document.createElement('div');
168
+ const dt = document.createElement('dt');
169
+ const dd = document.createElement('dd');
170
+ dt.textContent = term;
171
+ dd.textContent = value;
172
+ wrapper.append(dt, dd);
173
+ els.modelFacts.appendChild(wrapper);
174
+ }
175
+ }
176
+
177
+ async function initializeModel() {
178
+ try {
179
+ if (!window.ort) throw new Error('ONNX Runtime Web did not load from the pinned CDN.');
180
+
181
+ setRuntimeState('', 'Preparing model');
182
+ setProgress(3, 'Preparing class labels and model information…');
183
+ renderClassChips();
184
+ renderModelFacts();
185
+
186
+ ort.env.logLevel = 'error';
187
+ ort.env.wasm.numThreads = 1;
188
+ ort.env.wasm.proxy = false;
189
+ ort.env.wasm.wasmPaths = `https://cdn.jsdelivr.net/npm/onnxruntime-web@${APP.ortVersion}/dist/`;
190
+
191
+ setRuntimeState('', 'Downloading model');
192
+ const modelBytes = await fetchBinaryWithProgress(APP.modelUrl, (fraction, totalBytes) => {
193
+ const percent = 5 + (fraction * 66);
194
+ setProgress(percent, `Downloading ${formatBytes(totalBytes) || 'ONNX model'}…`);
195
+ });
196
+
197
+ setProgress(75, 'Creating optimized WebAssembly inference session…');
198
+ setRuntimeState('', 'Creating session');
199
+ state.session = await ort.InferenceSession.create(modelBytes, {
200
+ executionProviders: ['wasm'],
201
+ graphOptimizationLevel: 'all',
202
+ freeDimensionOverrides: { batch: 1 },
203
+ });
204
+
205
+ if (!state.session.inputNames.includes('input') || !state.session.outputNames.includes('logits')) {
206
+ throw new Error(`Unexpected ONNX interface: inputs ${state.session.inputNames.join(', ')}, outputs ${state.session.outputNames.join(', ')}`);
207
+ }
208
+
209
+ state.modelReady = true;
210
+ els.statusCard.classList.remove('error');
211
+ setProgress(100, 'Ready. The ONNX model has loaded successfully.');
212
+ setRuntimeState('ready', 'Model ready');
213
+ els.providerBadge.textContent = 'WASM';
214
+ updatePredictButton();
215
+ } catch (error) {
216
+ setError(error.message || 'Model initialization failed.', error);
217
+ }
218
+ }
219
+
220
+ async function decodeImage(file) {
221
+ if ('createImageBitmap' in window) {
222
+ try {
223
+ return await createImageBitmap(file, { imageOrientation: 'from-image' });
224
+ } catch (_) {
225
+ return createImageBitmap(file);
226
+ }
227
+ }
228
+
229
+ return new Promise((resolve, reject) => {
230
+ const url = URL.createObjectURL(file);
231
+ const image = new Image();
232
+ image.onload = () => {
233
+ URL.revokeObjectURL(url);
234
+ resolve(image);
235
+ };
236
+ image.onerror = () => {
237
+ URL.revokeObjectURL(url);
238
+ reject(new Error('The selected file could not be decoded as an image.'));
239
+ };
240
+ image.src = url;
241
+ });
242
+ }
243
+
244
+ function resetResults() {
245
+ els.results.classList.add('hidden');
246
+ els.emptyResult.classList.remove('hidden');
247
+ els.topThree.replaceChildren();
248
+ els.probabilityList.replaceChildren();
249
+ }
250
+
251
+ function clearImage() {
252
+ if (state.bitmap && typeof state.bitmap.close === 'function') state.bitmap.close();
253
+ if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
254
+ state.bitmap = null;
255
+ state.objectUrl = null;
256
+ state.file = null;
257
+ els.fileInput.value = '';
258
+ els.imagePreview.removeAttribute('src');
259
+ els.previewWrap.classList.add('hidden');
260
+ els.dropZone.classList.remove('hidden');
261
+ els.fileName.textContent = '';
262
+ els.fileDetails.textContent = '';
263
+ els.inputMessage.textContent = '';
264
+ els.inputMessage.classList.remove('error');
265
+ resetResults();
266
+ updatePredictButton();
267
+ }
268
+
269
+ async function handleFile(file) {
270
+ if (!file) return;
271
+ if (!file.type.startsWith('image/')) {
272
+ els.inputMessage.textContent = 'Select a valid image file.';
273
+ els.inputMessage.classList.add('error');
274
+ return;
275
+ }
276
+
277
+ try {
278
+ els.inputMessage.textContent = 'Decoding image…';
279
+ els.inputMessage.classList.remove('error');
280
+
281
+ if (state.bitmap && typeof state.bitmap.close === 'function') state.bitmap.close();
282
+ if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
283
+
284
+ state.bitmap = await decodeImage(file);
285
+ state.file = file;
286
+ state.objectUrl = URL.createObjectURL(file);
287
+
288
+ els.imagePreview.src = state.objectUrl;
289
+ els.fileName.textContent = file.name;
290
+ els.fileDetails.textContent = `${state.bitmap.width} × ${state.bitmap.height} · ${formatBytes(file.size)}`;
291
+ els.dropZone.classList.add('hidden');
292
+ els.previewWrap.classList.remove('hidden');
293
+ els.inputMessage.textContent = state.modelReady ? 'Image ready for classification.' : 'Image ready. Waiting for the model to finish loading.';
294
+ resetResults();
295
+ updatePredictButton();
296
+ } catch (error) {
297
+ els.inputMessage.textContent = error.message || 'Could not read this image.';
298
+ els.inputMessage.classList.add('error');
299
+ clearImage();
300
+ }
301
+ }
302
+
303
+ function preprocessImage(bitmap) {
304
+ const canvas = els.canvas;
305
+ const context = canvas.getContext('2d', { willReadFrequently: true });
306
+ canvas.width = APP.imageSize;
307
+ canvas.height = APP.imageSize;
308
+ context.clearRect(0, 0, APP.imageSize, APP.imageSize);
309
+ context.imageSmoothingEnabled = true;
310
+ context.imageSmoothingQuality = 'high';
311
+ context.drawImage(bitmap, 0, 0, APP.imageSize, APP.imageSize);
312
+
313
+ const rgba = context.getImageData(0, 0, APP.imageSize, APP.imageSize).data;
314
+ const planeSize = APP.imageSize * APP.imageSize;
315
+ const nchw = new Float32Array(3 * planeSize);
316
+
317
+ for (let pixel = 0; pixel < planeSize; pixel += 1) {
318
+ const source = pixel * 4;
319
+ nchw[pixel] = ((rgba[source] / 255) - APP.mean[0]) / APP.std[0];
320
+ nchw[planeSize + pixel] = ((rgba[source + 1] / 255) - APP.mean[1]) / APP.std[1];
321
+ nchw[(2 * planeSize) + pixel] = ((rgba[source + 2] / 255) - APP.mean[2]) / APP.std[2];
322
+ }
323
+
324
+ return new ort.Tensor('float32', nchw, [1, 3, APP.imageSize, APP.imageSize]);
325
+ }
326
+
327
+ function softmax(logits) {
328
+ const maximum = Math.max(...logits);
329
+ const exponentials = logits.map(value => Math.exp(value - maximum));
330
+ const denominator = exponentials.reduce((sum, value) => sum + value, 0);
331
+ return exponentials.map(value => value / denominator);
332
+ }
333
+
334
+ function renderResults(probabilities, timings) {
335
+ const ranked = probabilities
336
+ .map((probability, index) => ({ label: state.labels[index], probability }))
337
+ .sort((a, b) => b.probability - a.probability);
338
+
339
+ const winner = ranked[0];
340
+ const confidencePercent = winner.probability * 100;
341
+
342
+ els.predictedClass.textContent = titleCase(winner.label);
343
+ els.confidenceValue.textContent = `${confidencePercent.toFixed(1)}%`;
344
+ els.confidenceRing.style.setProperty('--confidence', `${winner.probability * 360}deg`);
345
+ els.confidenceRing.setAttribute('aria-label', `${confidencePercent.toFixed(1)} percent confidence`);
346
+
347
+ els.topThree.replaceChildren();
348
+ for (const item of ranked.slice(0, 3)) {
349
+ const row = document.createElement('li');
350
+ const name = document.createElement('span');
351
+ const value = document.createElement('span');
352
+ name.className = 'class-name';
353
+ value.className = 'class-prob';
354
+ name.textContent = item.label;
355
+ value.textContent = `${(item.probability * 100).toFixed(2)}%`;
356
+ row.append(name, value);
357
+ els.topThree.appendChild(row);
358
+ }
359
+
360
+ els.probabilityList.replaceChildren();
361
+ for (const item of ranked) {
362
+ const row = document.createElement('div');
363
+ row.className = 'probability-row';
364
+ const name = document.createElement('span');
365
+ name.className = 'name';
366
+ name.textContent = item.label;
367
+ const track = document.createElement('div');
368
+ track.className = 'bar-track';
369
+ const fill = document.createElement('div');
370
+ fill.className = 'bar-fill';
371
+ fill.style.width = `${Math.max(item.probability * 100, 0.3)}%`;
372
+ track.appendChild(fill);
373
+ const value = document.createElement('span');
374
+ value.className = 'value';
375
+ value.textContent = `${(item.probability * 100).toFixed(2)}%`;
376
+ row.append(name, track, value);
377
+ els.probabilityList.appendChild(row);
378
+ }
379
+
380
+ els.preprocessTime.textContent = formatMilliseconds(timings.preprocess);
381
+ els.inferenceTime.textContent = formatMilliseconds(timings.inference);
382
+ els.totalTime.textContent = formatMilliseconds(timings.total);
383
+ els.emptyResult.classList.add('hidden');
384
+ els.results.classList.remove('hidden');
385
+ }
386
+
387
+ async function predict() {
388
+ if (!state.modelReady || !state.bitmap || state.predicting) return;
389
+
390
+ state.predicting = true;
391
+ updatePredictButton();
392
+ els.predictButtonText.textContent = 'Classifying…';
393
+ els.buttonSpinner.classList.remove('hidden');
394
+ els.inputMessage.textContent = 'Running browser-side inference…';
395
+ els.inputMessage.classList.remove('error');
396
+
397
+ try {
398
+ const totalStart = performance.now();
399
+ const preprocessStart = performance.now();
400
+ const inputTensor = preprocessImage(state.bitmap);
401
+ const preprocessEnd = performance.now();
402
+
403
+ const inferenceStart = performance.now();
404
+ const outputs = await state.session.run({ input: inputTensor });
405
+ const inferenceEnd = performance.now();
406
+
407
+ const logits = Array.from(outputs.logits.data);
408
+ if (logits.length !== state.labels.length) {
409
+ throw new Error(`Expected ${state.labels.length} logits but received ${logits.length}.`);
410
+ }
411
+
412
+ const probabilities = softmax(logits);
413
+ const totalEnd = performance.now();
414
+ renderResults(probabilities, {
415
+ preprocess: preprocessEnd - preprocessStart,
416
+ inference: inferenceEnd - inferenceStart,
417
+ total: totalEnd - totalStart,
418
+ });
419
+ els.inputMessage.textContent = 'Prediction completed locally in this browser.';
420
+ } catch (error) {
421
+ console.error(error);
422
+ els.inputMessage.textContent = error.message || 'Inference failed.';
423
+ els.inputMessage.classList.add('error');
424
+ } finally {
425
+ state.predicting = false;
426
+ els.predictButtonText.textContent = 'Classify image';
427
+ els.buttonSpinner.classList.add('hidden');
428
+ updatePredictButton();
429
+ }
430
+ }
431
+
432
+ function preventDefaults(event) {
433
+ event.preventDefault();
434
+ event.stopPropagation();
435
+ }
436
+
437
+ ['dragenter', 'dragover'].forEach(eventName => {
438
+ els.dropZone.addEventListener(eventName, event => {
439
+ preventDefaults(event);
440
+ els.dropZone.classList.add('dragging');
441
+ });
442
+ });
443
+
444
+ ['dragleave', 'drop'].forEach(eventName => {
445
+ els.dropZone.addEventListener(eventName, event => {
446
+ preventDefaults(event);
447
+ els.dropZone.classList.remove('dragging');
448
+ });
449
+ });
450
+
451
+ els.dropZone.addEventListener('drop', event => handleFile(event.dataTransfer.files[0]));
452
+ els.dropZone.addEventListener('keydown', event => {
453
+ if (event.key === 'Enter' || event.key === ' ') {
454
+ event.preventDefault();
455
+ els.fileInput.click();
456
+ }
457
+ });
458
+ els.fileInput.addEventListener('change', event => handleFile(event.target.files[0]));
459
+ els.clearButton.addEventListener('click', clearImage);
460
+ els.predictButton.addEventListener('click', predict);
461
+ window.addEventListener('beforeunload', () => {
462
+ if (state.bitmap && typeof state.bitmap.close === 'function') state.bitmap.close();
463
+ if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
464
+ });
465
+
466
+ initializeModel();
index.html CHANGED
@@ -1,19 +1,170 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="theme-color" content="#0d7a62">
7
+ <meta name="description" content="Static browser-side waste image classification with MS-SE-EfficientNet-B0 and ONNX Runtime Web.">
8
+ <title>MS-SE Waste Classifier</title>
9
+ <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
10
+ <link rel="stylesheet" href="styles.css">
11
+ </head>
12
+ <body>
13
+ <header class="site-header">
14
+ <div class="brand">
15
+ <div class="brand-mark" aria-hidden="true"></div>
16
+ <div>
17
+ <p class="eyebrow">Browser-side waste recognition</p>
18
+ <h1>MS-SE Waste Classifier</h1>
19
+ </div>
20
+ </div>
21
+ <div class="runtime-pill" id="runtimePill" aria-live="polite">
22
+ <span class="status-dot" id="statusDot"></span>
23
+ <span id="runtimeText">Preparing runtime</span>
24
+ </div>
25
+ </header>
26
+
27
+ <main class="page-shell">
28
+ <section class="hero-card">
29
+ <div class="hero-copy">
30
+ <p class="kicker">MS-SE-EfficientNet-B0</p>
31
+ <h2>Classify a waste image entirely in your browser.</h2>
32
+ <p>
33
+ Upload one image to obtain a ten-class probability distribution. The model uses
34
+ average, maximum, and standard-deviation channel descriptors with bounded residual attention.
35
+ </p>
36
+ <div class="privacy-note">
37
+ <span aria-hidden="true">🔒</span>
38
+ <span>The selected image remains on this device during inference.</span>
39
+ </div>
40
+ </div>
41
+ <dl class="model-facts" id="modelFacts">
42
+ <div><dt>Input</dt><dd>224 × 224 RGB</dd></div>
43
+ <div><dt>Classes</dt><dd>10</dd></div>
44
+ <div><dt>Parameters</dt><dd>4.234M</dd></div>
45
+ <div><dt>Runtime</dt><dd>WebAssembly</dd></div>
46
+ </dl>
47
+ </section>
48
+
49
+ <section class="workspace" aria-label="Image classification workspace">
50
+ <article class="panel upload-panel">
51
+ <div class="panel-heading">
52
+ <div>
53
+ <p class="step-label">Step 1</p>
54
+ <h2>Select an image</h2>
55
+ </div>
56
+ <button class="text-button" id="clearButton" type="button" disabled>Clear</button>
57
+ </div>
58
+
59
+ <input id="fileInput" class="visually-hidden" type="file" accept="image/jpeg,image/png,image/webp,image/bmp">
60
+ <label class="drop-zone" id="dropZone" for="fileInput" tabindex="0">
61
+ <div class="upload-icon" aria-hidden="true">
62
+ <svg viewBox="0 0 24 24" role="img">
63
+ <path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14.5v3A2.5 2.5 0 0 0 7.5 20h9a2.5 2.5 0 0 0 2.5-2.5v-3"/>
64
+ </svg>
65
+ </div>
66
+ <strong>Drop an image here</strong>
67
+ <span>or click to browse</span>
68
+ <small>JPEG, PNG, WebP, or BMP</small>
69
+ </label>
70
+
71
+ <div class="preview-wrap hidden" id="previewWrap">
72
+ <img id="imagePreview" alt="Selected waste image preview">
73
+ <div class="image-meta">
74
+ <strong id="fileName"></strong>
75
+ <span id="fileDetails"></span>
76
+ </div>
77
+ </div>
78
+
79
+ <button class="primary-button" id="predictButton" type="button" disabled>
80
+ <span id="predictButtonText">Classify image</span>
81
+ <span class="button-spinner hidden" id="buttonSpinner" aria-hidden="true"></span>
82
+ </button>
83
+ <p class="inline-message" id="inputMessage" role="status"></p>
84
+ </article>
85
+
86
+ <article class="panel result-panel">
87
+ <div class="panel-heading">
88
+ <div>
89
+ <p class="step-label">Step 2</p>
90
+ <h2>Prediction</h2>
91
+ </div>
92
+ <span class="provider-badge" id="providerBadge">WASM</span>
93
+ </div>
94
+
95
+ <div class="empty-result" id="emptyResult">
96
+ <div class="empty-result-icon" aria-hidden="true">◎</div>
97
+ <h3>Results will appear here</h3>
98
+ <p>Load the model, select an image, and press “Classify image.”</p>
99
+ </div>
100
+
101
+ <div class="results hidden" id="results">
102
+ <div class="primary-result">
103
+ <div>
104
+ <p class="result-label">Predicted class</p>
105
+ <h3 id="predictedClass">—</h3>
106
+ </div>
107
+ <div class="confidence-ring" id="confidenceRing" aria-label="Prediction confidence">
108
+ <span id="confidenceValue">0%</span>
109
+ </div>
110
+ </div>
111
+
112
+ <div class="top-three-wrap">
113
+ <h4>Top three</h4>
114
+ <ol class="top-three" id="topThree"></ol>
115
+ </div>
116
+
117
+ <details class="all-probabilities" open>
118
+ <summary>All class probabilities</summary>
119
+ <div class="probability-list" id="probabilityList"></div>
120
+ </details>
121
+
122
+ <div class="timing-grid">
123
+ <div><span>Preprocess</span><strong id="preprocessTime">—</strong></div>
124
+ <div><span>Inference</span><strong id="inferenceTime">—</strong></div>
125
+ <div><span>Total</span><strong id="totalTime">—</strong></div>
126
+ </div>
127
+ </div>
128
+ </article>
129
+ </section>
130
+
131
+ <section class="status-card" aria-labelledby="modelStatusTitle">
132
+ <div class="status-title-row">
133
+ <div>
134
+ <p class="step-label">Model status</p>
135
+ <h2 id="modelStatusTitle">Runtime initialization</h2>
136
+ </div>
137
+ <strong id="loadPercent">0%</strong>
138
+ </div>
139
+ <div class="progress-track" aria-hidden="true">
140
+ <div class="progress-bar" id="progressBar"></div>
141
+ </div>
142
+ <p id="statusDetail" aria-live="polite">Loading ONNX Runtime Web…</p>
143
+ </section>
144
+
145
+ <section class="information-grid">
146
+ <article class="info-card">
147
+ <h2>Supported classes</h2>
148
+ <div class="class-chips" id="classChips"></div>
149
+ </article>
150
+ <article class="info-card">
151
+ <h2>Notebook-matched preprocessing</h2>
152
+ <p>Direct resize to 224 × 224, RGB conversion, scaling to [0,1], and ImageNet normalization.</p>
153
+ </article>
154
+ <article class="info-card caution-card">
155
+ <h2>Research use</h2>
156
+ <p>Visual ambiguity remains possible, especially among related packaging materials and general trash.</p>
157
+ </article>
158
+ </section>
159
+ </main>
160
+
161
+ <footer>
162
+ <p>Static ONNX inference · MS-SE-EfficientNet-B0 · Ten waste classes</p>
163
+ </footer>
164
+
165
+ <canvas id="preprocessCanvas" width="224" height="224" class="visually-hidden" aria-hidden="true"></canvas>
166
+
167
+ <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.min.js" crossorigin="anonymous"></script>
168
+ <script src="app.js"></script>
169
+ </body>
170
  </html>
ms_se_efficientnet_b0.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ac619ed4b912b3aa5c2bba39555a95441d532b16dc2e21dd5166c1acf703ba85
3
+ size 16924707
styles.css ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f2f6f4;
3
+ --surface: #ffffff;
4
+ --surface-soft: #f7faf8;
5
+ --ink: #14231e;
6
+ --muted: #65736d;
7
+ --border: #d7e1dc;
8
+ --accent: #0d7a62;
9
+ --accent-dark: #075c49;
10
+ --accent-soft: #e3f3ed;
11
+ --blue: #2874b8;
12
+ --warning: #8b5a00;
13
+ --danger: #a33131;
14
+ --shadow: 0 18px 50px rgba(24, 56, 44, 0.09);
15
+ --radius-xl: 24px;
16
+ --radius-lg: 18px;
17
+ --radius-md: 13px;
18
+ }
19
+
20
+ * { box-sizing: border-box; }
21
+
22
+ html { color-scheme: light; }
23
+
24
+ body {
25
+ margin: 0;
26
+ min-height: 100vh;
27
+ color: var(--ink);
28
+ background:
29
+ radial-gradient(circle at 4% 2%, rgba(13, 122, 98, 0.09), transparent 28rem),
30
+ radial-gradient(circle at 95% 5%, rgba(40, 116, 184, 0.08), transparent 24rem),
31
+ var(--bg);
32
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
33
+ line-height: 1.5;
34
+ }
35
+
36
+ button, input { font: inherit; }
37
+ button { cursor: pointer; }
38
+
39
+ .site-header {
40
+ width: min(1240px, calc(100% - 32px));
41
+ margin: 20px auto 0;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ gap: 20px;
46
+ }
47
+
48
+ .brand { display: flex; align-items: center; gap: 13px; }
49
+ .brand-mark {
50
+ display: grid;
51
+ place-items: center;
52
+ width: 48px;
53
+ height: 48px;
54
+ border-radius: 15px;
55
+ color: #fff;
56
+ background: linear-gradient(145deg, var(--accent), #15a379);
57
+ font-size: 26px;
58
+ box-shadow: 0 10px 25px rgba(13, 122, 98, 0.22);
59
+ }
60
+ .eyebrow, .kicker, .step-label {
61
+ margin: 0;
62
+ color: var(--accent);
63
+ font-size: 0.72rem;
64
+ font-weight: 800;
65
+ letter-spacing: 0.1em;
66
+ text-transform: uppercase;
67
+ }
68
+ .brand h1 { margin: 1px 0 0; font-size: 1.18rem; letter-spacing: -0.02em; }
69
+
70
+ .runtime-pill {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ gap: 9px;
74
+ padding: 9px 13px;
75
+ border: 1px solid var(--border);
76
+ border-radius: 999px;
77
+ background: rgba(255,255,255,0.8);
78
+ color: var(--muted);
79
+ font-size: 0.84rem;
80
+ font-weight: 700;
81
+ backdrop-filter: blur(8px);
82
+ }
83
+ .status-dot {
84
+ width: 9px;
85
+ height: 9px;
86
+ border-radius: 50%;
87
+ background: #d19719;
88
+ box-shadow: 0 0 0 4px rgba(209, 151, 25, 0.13);
89
+ }
90
+ .runtime-pill.ready .status-dot { background: #15936f; box-shadow: 0 0 0 4px rgba(21,147,111,.13); }
91
+ .runtime-pill.error .status-dot { background: var(--danger); box-shadow: 0 0 0 4px rgba(163,49,49,.13); }
92
+
93
+ .page-shell { width: min(1240px, calc(100% - 32px)); margin: 24px auto 46px; }
94
+
95
+ .hero-card {
96
+ position: relative;
97
+ overflow: hidden;
98
+ display: grid;
99
+ grid-template-columns: minmax(0, 1.55fr) minmax(330px, .85fr);
100
+ gap: 38px;
101
+ padding: 38px;
102
+ border: 1px solid rgba(13, 122, 98, 0.12);
103
+ border-radius: var(--radius-xl);
104
+ background: linear-gradient(135deg, #ffffff 0%, #f3fbf7 55%, #edf6fb 100%);
105
+ box-shadow: var(--shadow);
106
+ }
107
+ .hero-card::after {
108
+ content: "";
109
+ position: absolute;
110
+ width: 300px;
111
+ height: 300px;
112
+ right: -140px;
113
+ top: -170px;
114
+ border: 48px solid rgba(13, 122, 98, 0.055);
115
+ border-radius: 50%;
116
+ }
117
+ .hero-copy { position: relative; z-index: 1; }
118
+ .hero-copy h2 {
119
+ max-width: 760px;
120
+ margin: 8px 0 12px;
121
+ font-size: clamp(2rem, 4.1vw, 3.35rem);
122
+ line-height: 1.04;
123
+ letter-spacing: -0.045em;
124
+ }
125
+ .hero-copy > p:not(.kicker) { max-width: 760px; margin: 0; color: var(--muted); font-size: 1.04rem; }
126
+ .privacy-note {
127
+ display: inline-flex;
128
+ align-items: center;
129
+ gap: 9px;
130
+ margin-top: 20px;
131
+ padding: 10px 13px;
132
+ border-radius: 12px;
133
+ color: var(--accent-dark);
134
+ background: var(--accent-soft);
135
+ font-size: .88rem;
136
+ font-weight: 700;
137
+ }
138
+ .model-facts {
139
+ position: relative;
140
+ z-index: 1;
141
+ align-self: center;
142
+ display: grid;
143
+ grid-template-columns: 1fr 1fr;
144
+ gap: 12px;
145
+ margin: 0;
146
+ }
147
+ .model-facts div {
148
+ padding: 18px;
149
+ border: 1px solid rgba(13,122,98,.13);
150
+ border-radius: 15px;
151
+ background: rgba(255,255,255,.82);
152
+ }
153
+ .model-facts dt { color: var(--muted); font-size: .76rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
154
+ .model-facts dd { margin: 5px 0 0; font-size: 1.08rem; font-weight: 800; }
155
+
156
+ .workspace { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-top: 22px; }
157
+ .panel, .status-card, .info-card {
158
+ border: 1px solid var(--border);
159
+ border-radius: var(--radius-lg);
160
+ background: var(--surface);
161
+ box-shadow: 0 12px 32px rgba(28, 55, 45, .055);
162
+ }
163
+ .panel { min-height: 590px; padding: 24px; }
164
+ .panel-heading, .status-title-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
165
+ .panel-heading h2, .status-title-row h2, .info-card h2 { margin: 3px 0 0; font-size: 1.15rem; letter-spacing: -.02em; }
166
+ .text-button {
167
+ border: 0;
168
+ padding: 7px 9px;
169
+ border-radius: 9px;
170
+ color: var(--accent);
171
+ background: transparent;
172
+ font-weight: 750;
173
+ }
174
+ .text-button:hover:not(:disabled) { background: var(--accent-soft); }
175
+ button:disabled { cursor: not-allowed; opacity: .52; }
176
+
177
+ .drop-zone {
178
+ min-height: 300px;
179
+ margin-top: 22px;
180
+ display: flex;
181
+ flex-direction: column;
182
+ align-items: center;
183
+ justify-content: center;
184
+ gap: 7px;
185
+ border: 1.5px dashed #a9bdb4;
186
+ border-radius: 17px;
187
+ background: var(--surface-soft);
188
+ text-align: center;
189
+ transition: border-color .2s, background .2s, transform .2s;
190
+ }
191
+ .drop-zone:hover, .drop-zone:focus-visible, .drop-zone.dragging {
192
+ border-color: var(--accent);
193
+ background: #eef8f3;
194
+ transform: translateY(-1px);
195
+ outline: none;
196
+ }
197
+ .drop-zone strong { font-size: 1.08rem; }
198
+ .drop-zone span { color: var(--muted); }
199
+ .drop-zone small { margin-top: 8px; color: #87958f; }
200
+ .upload-icon {
201
+ display: grid;
202
+ place-items: center;
203
+ width: 62px;
204
+ height: 62px;
205
+ margin-bottom: 8px;
206
+ border-radius: 19px;
207
+ color: var(--accent);
208
+ background: var(--accent-soft);
209
+ }
210
+ .upload-icon svg { width: 29px; height: 29px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
211
+
212
+ .preview-wrap {
213
+ margin-top: 22px;
214
+ overflow: hidden;
215
+ border: 1px solid var(--border);
216
+ border-radius: 17px;
217
+ background: #eef2f0;
218
+ }
219
+ .preview-wrap img { display: block; width: 100%; height: 330px; object-fit: contain; background: #eef2f0; }
220
+ .image-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; background: #fff; }
221
+ .image-meta strong { max-width: 65%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
222
+ .image-meta span { color: var(--muted); font-size: .82rem; }
223
+
224
+ .primary-button {
225
+ width: 100%;
226
+ min-height: 49px;
227
+ margin-top: 18px;
228
+ display: inline-flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ gap: 10px;
232
+ border: 0;
233
+ border-radius: 13px;
234
+ color: #fff;
235
+ background: linear-gradient(135deg, var(--accent), #0d906f);
236
+ font-weight: 800;
237
+ box-shadow: 0 10px 22px rgba(13,122,98,.18);
238
+ transition: transform .16s, box-shadow .16s;
239
+ }
240
+ .primary-button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 13px 27px rgba(13,122,98,.23); }
241
+ .button-spinner { width: 17px; height: 17px; border: 2px solid rgba(255,255,255,.4); border-top-color: #fff; border-radius: 50%; animation: spin .75s linear infinite; }
242
+ @keyframes spin { to { transform: rotate(360deg); } }
243
+ .inline-message { min-height: 21px; margin: 9px 2px 0; color: var(--muted); font-size: .84rem; }
244
+ .inline-message.error { color: var(--danger); }
245
+
246
+ .provider-badge {
247
+ padding: 6px 9px;
248
+ border-radius: 999px;
249
+ color: var(--blue);
250
+ background: #e7f1fb;
251
+ font-size: .72rem;
252
+ font-weight: 850;
253
+ letter-spacing: .06em;
254
+ }
255
+ .empty-result { min-height: 470px; display: grid; place-content: center; justify-items: center; text-align: center; color: var(--muted); }
256
+ .empty-result-icon { display: grid; place-items: center; width: 70px; height: 70px; border-radius: 22px; color: var(--accent); background: var(--accent-soft); font-size: 2rem; }
257
+ .empty-result h3 { margin: 17px 0 5px; color: var(--ink); }
258
+ .empty-result p { max-width: 330px; margin: 0; }
259
+
260
+ .results { margin-top: 20px; }
261
+ .primary-result {
262
+ display: flex;
263
+ align-items: center;
264
+ justify-content: space-between;
265
+ gap: 18px;
266
+ padding: 20px;
267
+ border-radius: 17px;
268
+ background: linear-gradient(135deg, #effaf5, #eef6fc);
269
+ }
270
+ .result-label { margin: 0; color: var(--muted); font-size: .78rem; font-weight: 750; text-transform: uppercase; letter-spacing: .07em; }
271
+ .primary-result h3 { margin: 5px 0 0; font-size: clamp(1.55rem, 4vw, 2.2rem); text-transform: capitalize; letter-spacing: -.035em; }
272
+ .confidence-ring {
273
+ --confidence: 0deg;
274
+ flex: 0 0 auto;
275
+ display: grid;
276
+ place-items: center;
277
+ width: 86px;
278
+ height: 86px;
279
+ border-radius: 50%;
280
+ background: conic-gradient(var(--accent) var(--confidence), #dbe8e2 0);
281
+ position: relative;
282
+ }
283
+ .confidence-ring::before { content: ""; position: absolute; inset: 8px; border-radius: 50%; background: #fff; }
284
+ .confidence-ring span { position: relative; z-index: 1; font-size: 1.03rem; font-weight: 850; }
285
+ .top-three-wrap { margin-top: 20px; }
286
+ .top-three-wrap h4, .all-probabilities summary { margin: 0 0 10px; font-size: .9rem; }
287
+ .top-three { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; counter-reset: rank; }
288
+ .top-three li { counter-increment: rank; display: grid; grid-template-columns: 29px 1fr auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 11px; }
289
+ .top-three li::before { content: counter(rank); display: grid; place-items: center; width: 25px; height: 25px; border-radius: 8px; color: var(--accent-dark); background: var(--accent-soft); font-weight: 850; font-size: .78rem; }
290
+ .top-three .class-name { font-weight: 750; text-transform: capitalize; }
291
+ .top-three .class-prob { color: var(--muted); font-variant-numeric: tabular-nums; }
292
+ .all-probabilities { margin-top: 18px; }
293
+ .all-probabilities summary { cursor: pointer; font-weight: 800; }
294
+ .probability-list { display: grid; gap: 8px; margin-top: 11px; }
295
+ .probability-row { display: grid; grid-template-columns: 82px 1fr 54px; align-items: center; gap: 9px; font-size: .78rem; }
296
+ .probability-row .name { text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; }
297
+ .bar-track { height: 8px; overflow: hidden; border-radius: 999px; background: #e4ece8; }
298
+ .bar-fill { height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, var(--accent), #34a8a0); transition: width .45s ease; }
299
+ .probability-row .value { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
300
+ .timing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin-top: 19px; }
301
+ .timing-grid div { padding: 11px; border-radius: 11px; background: var(--surface-soft); }
302
+ .timing-grid span { display: block; color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; }
303
+ .timing-grid strong { display: block; margin-top: 3px; font-size: .9rem; font-variant-numeric: tabular-nums; }
304
+
305
+ .status-card { margin-top: 22px; padding: 20px 23px; }
306
+ .status-title-row strong { color: var(--accent); font-variant-numeric: tabular-nums; }
307
+ .progress-track { height: 9px; margin-top: 15px; overflow: hidden; border-radius: 999px; background: #e2eae6; }
308
+ .progress-bar { width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), #31a691); transition: width .25s ease; }
309
+ .status-card > p { margin: 10px 0 0; color: var(--muted); font-size: .87rem; }
310
+ .status-card.error { border-color: rgba(163,49,49,.3); }
311
+ .status-card.error .progress-bar { background: var(--danger); }
312
+
313
+ .information-grid { display: grid; grid-template-columns: 1.15fr 1fr 1fr; gap: 16px; margin-top: 22px; }
314
+ .info-card { padding: 21px; }
315
+ .info-card p { margin: 10px 0 0; color: var(--muted); font-size: .9rem; }
316
+ .class-chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 13px; }
317
+ .class-chip { padding: 6px 9px; border: 1px solid #cfe0d8; border-radius: 999px; color: var(--accent-dark); background: #f0f8f4; font-size: .76rem; font-weight: 750; text-transform: capitalize; }
318
+ .caution-card { background: #fffdf7; border-color: #eadfbd; }
319
+ .caution-card h2 { color: var(--warning); }
320
+
321
+ footer { padding: 22px 16px 34px; text-align: center; color: var(--muted); font-size: .82rem; }
322
+ .hidden { display: none !important; }
323
+ .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
324
+
325
+ @media (max-width: 900px) {
326
+ .hero-card { grid-template-columns: 1fr; padding: 28px; }
327
+ .model-facts { grid-template-columns: repeat(4, 1fr); }
328
+ .workspace { grid-template-columns: 1fr; }
329
+ .panel { min-height: unset; }
330
+ .information-grid { grid-template-columns: 1fr; }
331
+ }
332
+
333
+ @media (max-width: 650px) {
334
+ .site-header { align-items: flex-start; }
335
+ .runtime-pill { max-width: 150px; }
336
+ .runtime-pill span:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
337
+ .page-shell, .site-header { width: min(100% - 20px, 1240px); }
338
+ .hero-card { padding: 23px 20px; }
339
+ .hero-copy h2 { font-size: 2.05rem; }
340
+ .model-facts { grid-template-columns: 1fr 1fr; }
341
+ .panel { padding: 19px; }
342
+ .drop-zone { min-height: 245px; }
343
+ .preview-wrap img { height: 270px; }
344
+ .primary-result { align-items: flex-start; }
345
+ .confidence-ring { width: 75px; height: 75px; }
346
+ .probability-row { grid-template-columns: 70px 1fr 48px; }
347
+ .timing-grid { grid-template-columns: 1fr; }
348
+ }