fique5 commited on
Commit
3b315f3
·
verified ·
1 Parent(s): 189f03e

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. README.md +36 -3
  2. best.onnx +3 -0
  3. index.html +75 -0
  4. labels.json +17 -0
  5. script.js +395 -0
  6. style.css +299 -0
README.md CHANGED
@@ -1,3 +1,36 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WaterMeter AI
2
+
3
+ A simple browser-based water meter reading demo using ONNX Runtime Web and a YOLOv8 ONNX model.
4
+
5
+ ## Files
6
+
7
+ - `index.html` — UI and page structure
8
+ - `style.css` — app styling
9
+ - `script.js` — model loading, image preprocessing, inference, drawing, and reading extraction
10
+ - `labels.json` — model class labels reference
11
+ - `best.onnx` — your YOLOv8 ONNX model file (must be added separately)
12
+
13
+ ## Setup
14
+
15
+ 1. Place your `best.onnx` model in the same folder as `index.html`.
16
+ 2. Open the files from a static server or deploy to a static host such as Hugging Face Spaces.
17
+
18
+ > No Python server is required for the repository structure below.
19
+
20
+ ## Usage
21
+
22
+ 1. Upload or drag a water meter image.
23
+ 2. Press **Analyze Image**.
24
+ 3. The app will show detected boxes and assemble meter digits into a reading.
25
+
26
+ ## Notes
27
+
28
+ - The app expects a YOLOv8 ONNX model that returns standard detection output: `[x, y, w, h, obj, class01, class02, ...]`.
29
+ - For best results, use a model trained on your meter dataset.
30
+ - If `best.onnx` is missing, the model load will fail.
31
+
32
+ ## Hugging Face Spaces
33
+
34
+ This project is ready to deploy as a static Space. Upload the repository including `best.onnx` to the Space root.
35
+
36
+ If you want to use a remote model URL instead, update `MODEL_PATH` in `script.js`.
best.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d78f76f262590562d462d076eebe326df1f804bb0b2c5cfaf4155f773cffbcc2
3
+ size 12274732
index.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>WaterMeter AI</title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
10
+ </head>
11
+
12
+ <body>
13
+ <div class="background"></div>
14
+ <main class="container">
15
+ <header class="hero">
16
+ <div>
17
+ <h1>💧 WaterMeter AI</h1>
18
+ <p>Upload a meter photo and read the digits with a YOLOv8 ONNX model.</p>
19
+ </div>
20
+ <div class="status-group">
21
+ <span id="modelStatus" class="badge loading">Loading model...</span>
22
+ <span id="appHint" class="hint">Upload <strong>best.onnx</strong> to the repository root.</span>
23
+ </div>
24
+ </header>
25
+
26
+ <section class="upload-card">
27
+ <div id="dropZone" class="drop-zone">
28
+ <input type="file" id="imageInput" accept="image/*" hidden />
29
+ <div class="upload-content">
30
+ <h2>Upload or drop an image</h2>
31
+ <p>Drag and drop a water meter photo, or browse locally.</p>
32
+ <button id="browseBtn" type="button">Browse Image</button>
33
+ </div>
34
+ </div>
35
+ </section>
36
+
37
+ <section class="viewer">
38
+ <div class="image-card">
39
+ <div class="card-header">
40
+ <h3>Preview</h3>
41
+ <span id="imageInfo" class="muted">No image selected.</span>
42
+ </div>
43
+ <div class="canvas-viewport">
44
+ <canvas id="canvas"></canvas>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="result-card">
49
+ <h3>Prediction</h3>
50
+ <div class="reading">
51
+ <span class="title">Meter Reading</span>
52
+ <span id="meterReading">--</span>
53
+ </div>
54
+ <div class="confidence">
55
+ <span class="title">Average Confidence</span>
56
+ <span id="confidence">--</span>
57
+ </div>
58
+ <div class="prediction-footer">
59
+ <button id="predictBtn" class="primary-button" type="button">Analyze Image</button>
60
+ </div>
61
+ </div>
62
+ </section>
63
+
64
+ <section class="detections">
65
+ <h2>Detected Objects</h2>
66
+ <div id="detectionsList" class="detections-grid">
67
+ <p class="small-text">Upload an image and press Analyze Image to see detections.</p>
68
+ </div>
69
+ </section>
70
+ </main>
71
+
72
+ <script src="script.js"></script>
73
+ </body>
74
+
75
+ </html>
labels.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "classes": [
3
+ "meter",
4
+ "window",
5
+ "0",
6
+ "1",
7
+ "2",
8
+ "3",
9
+ "4",
10
+ "5",
11
+ "6",
12
+ "7",
13
+ "8",
14
+ "9",
15
+ "u"
16
+ ]
17
+ }
script.js ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MODEL_PATH = "https://huggingface.co/fique5/watermeter/resolve/main/best.onnx";;
2
+ const INPUT_SIZE = 640;
3
+ const CLASS_NAMES = [
4
+ "meter",
5
+ "window",
6
+ "0",
7
+ "1",
8
+ "2",
9
+ "3",
10
+ "4",
11
+ "5",
12
+ "6",
13
+ "7",
14
+ "8",
15
+ "9",
16
+ "u"
17
+ ];
18
+ const SCORE_THRESHOLD = 0.25;
19
+ const IOU_THRESHOLD = 0.45;
20
+ const MAX_BOXES = 120;
21
+
22
+ let session = null;
23
+ let selectedImage = null;
24
+
25
+ const canvas = document.getElementById("canvas");
26
+ const ctx = canvas.getContext("2d");
27
+ const imageInput = document.getElementById("imageInput");
28
+ const browseBtn = document.getElementById("browseBtn");
29
+ const dropZone = document.getElementById("dropZone");
30
+ const predictBtn = document.getElementById("predictBtn");
31
+ const modelStatus = document.getElementById("modelStatus");
32
+ const meterReading = document.getElementById("meterReading");
33
+ const confidence = document.getElementById("confidence");
34
+ const detectionsList = document.getElementById("detectionsList");
35
+ const imageInfo = document.getElementById("imageInfo");
36
+
37
+ async function loadModel() {
38
+ modelStatus.textContent = "Loading model...";
39
+ modelStatus.className = "badge loading";
40
+
41
+ try {
42
+ session = await ort.InferenceSession.create(MODEL_PATH, {
43
+ executionProviders: ["wasm"]
44
+ });
45
+
46
+ modelStatus.textContent = "Model ready";
47
+ modelStatus.className = "badge ready";
48
+ } catch (error) {
49
+ console.error(error);
50
+ modelStatus.textContent = "Model failed";
51
+ modelStatus.className = "badge error";
52
+ detectionsList.innerHTML =
53
+ "<p class=\"small-text\">Unable to load the ONNX model. Make sure best.onnx is present in the repository root.</p>";
54
+ }
55
+ }
56
+
57
+ function setDropZoneState(active) {
58
+ if (active) {
59
+ dropZone.classList.add("drag-over");
60
+ } else {
61
+ dropZone.classList.remove("drag-over");
62
+ }
63
+ }
64
+
65
+ browseBtn.addEventListener("click", () => imageInput.click());
66
+ imageInput.addEventListener("change", (event) => {
67
+ const file = event.target.files[0];
68
+ if (file) {
69
+ loadImage(file);
70
+ }
71
+ });
72
+
73
+ dropZone.addEventListener("dragover", (event) => {
74
+ event.preventDefault();
75
+ setDropZoneState(true);
76
+ });
77
+
78
+ dropZone.addEventListener("dragleave", () => setDropZoneState(false));
79
+
80
+ dropZone.addEventListener("drop", (event) => {
81
+ event.preventDefault();
82
+ setDropZoneState(false);
83
+ const file = event.dataTransfer.files[0];
84
+ if (file) {
85
+ loadImage(file);
86
+ }
87
+ });
88
+
89
+ window.addEventListener("dragover", (event) => {
90
+ event.preventDefault();
91
+ });
92
+
93
+ window.addEventListener("drop", (event) => {
94
+ event.preventDefault();
95
+ });
96
+
97
+ function loadImage(file) {
98
+ const img = new Image();
99
+ img.onload = () => {
100
+ selectedImage = img;
101
+ canvas.width = img.width;
102
+ canvas.height = img.height;
103
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
104
+ ctx.drawImage(img, 0, 0);
105
+ imageInfo.textContent = `${img.width}px × ${img.height}px`;
106
+ meterReading.textContent = "--";
107
+ confidence.textContent = "--";
108
+ detectionsList.innerHTML =
109
+ "<p class=\"small-text\">Ready to analyze. Press Analyze Image.</p>";
110
+ URL.revokeObjectURL(img.src);
111
+ };
112
+ img.src = URL.createObjectURL(file);
113
+ }
114
+
115
+ predictBtn.addEventListener("click", runPrediction);
116
+
117
+ async function runPrediction() {
118
+ if (!selectedImage) {
119
+ alert("Please upload an image first.");
120
+ return;
121
+ }
122
+
123
+ if (!session) {
124
+ alert("Model is not ready yet. Wait until the model finishes loading.");
125
+ return;
126
+ }
127
+
128
+ modelStatus.textContent = "Running inference...";
129
+ modelStatus.className = "badge loading";
130
+
131
+ try {
132
+ const prediction = await predictImage(selectedImage);
133
+ drawDetectionResults(prediction.detections);
134
+ updatePredictionUI(prediction);
135
+ modelStatus.textContent = "Ready";
136
+ modelStatus.className = "badge ready";
137
+ } catch (error) {
138
+ console.error(error);
139
+ modelStatus.textContent = "Inference failed";
140
+ modelStatus.className = "badge error";
141
+ detectionsList.innerHTML =
142
+ "<p class=\"small-text\">Inference failed. Check your model path and image format.</p>";
143
+ }
144
+ }
145
+
146
+ async function predictImage(image) {
147
+ const {tensor, ratio, pad, originalWidth, originalHeight} = prepareInput(image);
148
+ const inputName = session.inputNames[0];
149
+ const feeds = {};
150
+ feeds[inputName] = new ort.Tensor("float32", [1, 3, INPUT_SIZE, INPUT_SIZE], tensor);
151
+
152
+ const results = await session.run(feeds);
153
+ const outputName = session.outputNames[0];
154
+ const rawOutput = results[outputName];
155
+ const detections = decodeOutput(
156
+ rawOutput.data,
157
+ rawOutput.dims,
158
+ ratio,
159
+ pad,
160
+ originalWidth,
161
+ originalHeight
162
+ );
163
+
164
+ return {
165
+ detections,
166
+ reading: extractMeterReading(detections),
167
+ averageConfidence: computeAverageConfidence(detections)
168
+ };
169
+ }
170
+
171
+ function prepareInput(image) {
172
+ const letterbox = letterboxImage(image, INPUT_SIZE);
173
+ const imageData = letterbox.imageData;
174
+ const floatArray = new Float32Array(1 * 3 * INPUT_SIZE * INPUT_SIZE);
175
+
176
+ for (let y = 0; y < INPUT_SIZE; y++) {
177
+ for (let x = 0; x < INPUT_SIZE; x++) {
178
+ const idx = (y * INPUT_SIZE + x) * 4;
179
+ const r = imageData.data[idx] / 255;
180
+ const g = imageData.data[idx + 1] / 255;
181
+ const b = imageData.data[idx + 2] / 255;
182
+ const pos = y * INPUT_SIZE + x;
183
+ floatArray[pos] = r;
184
+ floatArray[INPUT_SIZE * INPUT_SIZE + pos] = g;
185
+ floatArray[2 * INPUT_SIZE * INPUT_SIZE + pos] = b;
186
+ }
187
+ }
188
+
189
+ return {
190
+ tensor: floatArray,
191
+ ratio: letterbox.ratio,
192
+ pad: letterbox.pad,
193
+ originalWidth: image.width,
194
+ originalHeight: image.height
195
+ };
196
+ }
197
+
198
+ function letterboxImage(image, size) {
199
+ const offscreen = document.createElement("canvas");
200
+ offscreen.width = size;
201
+ offscreen.height = size;
202
+ const ctxOff = offscreen.getContext("2d");
203
+ ctxOff.fillStyle = "#000";
204
+ ctxOff.fillRect(0, 0, size, size);
205
+
206
+ const ratio = Math.min(size / image.width, size / image.height);
207
+ const newWidth = Math.round(image.width * ratio);
208
+ const newHeight = Math.round(image.height * ratio);
209
+ const padX = Math.round((size - newWidth) / 2);
210
+ const padY = Math.round((size - newHeight) / 2);
211
+
212
+ ctxOff.drawImage(image, 0, 0, image.width, image.height, padX, padY, newWidth, newHeight);
213
+
214
+ return {
215
+ imageData: ctxOff.getImageData(0, 0, size, size),
216
+ ratio,
217
+ pad: { x: padX, y: padY }
218
+ };
219
+ }
220
+
221
+ function decodeOutput(data, dims, ratio, pad, originalWidth, originalHeight) {
222
+ const [batch, numBoxes, attributes] = dims;
223
+ const detections = [];
224
+
225
+ for (let i = 0; i < numBoxes; i++) {
226
+ const offset = i * attributes;
227
+ const x = data[offset];
228
+ const y = data[offset + 1];
229
+ const w = data[offset + 2];
230
+ const h = data[offset + 3];
231
+ const objectness = data[offset + 4];
232
+
233
+ let bestClass = -1;
234
+ let bestScore = 0;
235
+
236
+ for (let c = 0; c < CLASS_NAMES.length; c++) {
237
+ const classScore = data[offset + 5 + c];
238
+ const score = objectness * classScore;
239
+ if (score > bestScore) {
240
+ bestScore = score;
241
+ bestClass = c;
242
+ }
243
+ }
244
+
245
+ if (bestScore < SCORE_THRESHOLD) {
246
+ continue;
247
+ }
248
+
249
+ const x1 = (x - w / 2 - pad.x) / ratio;
250
+ const y1 = (y - h / 2 - pad.y) / ratio;
251
+ const x2 = (x + w / 2 - pad.x) / ratio;
252
+ const y2 = (y + h / 2 - pad.y) / ratio;
253
+
254
+ detections.push({
255
+ classIndex: bestClass,
256
+ label: CLASS_NAMES[bestClass],
257
+ score: bestScore,
258
+ x1: clamp(x1, 0, originalWidth),
259
+ y1: clamp(y1, 0, originalHeight),
260
+ x2: clamp(x2, 0, originalWidth),
261
+ y2: clamp(y2, 0, originalHeight)
262
+ });
263
+ }
264
+
265
+ return nonMaxSuppression(detections, IOU_THRESHOLD, MAX_BOXES);
266
+ }
267
+
268
+ function nonMaxSuppression(detections, iouThreshold, maxBoxes) {
269
+ const results = [];
270
+ const sorted = detections.sort((a, b) => b.score - a.score);
271
+
272
+ while (sorted.length && results.length < maxBoxes) {
273
+ const current = sorted.shift();
274
+ results.push(current);
275
+
276
+ for (let i = sorted.length - 1; i >= 0; i--) {
277
+ if (current.classIndex !== sorted[i].classIndex) {
278
+ continue;
279
+ }
280
+ if (intersectionOverUnion(current, sorted[i]) > iouThreshold) {
281
+ sorted.splice(i, 1);
282
+ }
283
+ }
284
+ }
285
+
286
+ return results;
287
+ }
288
+
289
+ function intersectionOverUnion(a, b) {
290
+ const x1 = Math.max(a.x1, b.x1);
291
+ const y1 = Math.max(a.y1, b.y1);
292
+ const x2 = Math.min(a.x2, b.x2);
293
+ const y2 = Math.min(a.y2, b.y2);
294
+
295
+ const width = Math.max(0, x2 - x1);
296
+ const height = Math.max(0, y2 - y1);
297
+ const intersection = width * height;
298
+ const union =
299
+ (a.x2 - a.x1) * (a.y2 - a.y1) +
300
+ (b.x2 - b.x1) * (b.y2 - b.y1) -
301
+ intersection;
302
+
303
+ return union === 0 ? 0 : intersection / union;
304
+ }
305
+
306
+ function drawDetectionResults(detections) {
307
+ if (!selectedImage) {
308
+ return;
309
+ }
310
+
311
+ canvas.width = selectedImage.width;
312
+ canvas.height = selectedImage.height;
313
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
314
+ ctx.drawImage(selectedImage, 0, 0);
315
+
316
+ detections.forEach((detection) => {
317
+ const width = detection.x2 - detection.x1;
318
+ const height = detection.y2 - detection.y1;
319
+ ctx.strokeStyle = detection.classIndex === 0 ? "#00d4ff" : "#ffb703";
320
+ ctx.lineWidth = Math.max(2, Math.round(canvas.width / 360));
321
+ ctx.strokeRect(detection.x1, detection.y1, width, height);
322
+
323
+ const label = `${detection.label} ${(detection.score * 100).toFixed(1)}%`;
324
+ ctx.font = `${Math.max(12, Math.round(canvas.width / 60))}px Inter`;
325
+ ctx.textBaseline = "top";
326
+ ctx.fillStyle = "rgba(0, 0, 0, 0.65)";
327
+ const textWidth = ctx.measureText(label).width + 16;
328
+ const textHeight = parseInt(ctx.font, 10) + 10;
329
+
330
+ const textX = detection.x1;
331
+ const textY = Math.max(0, detection.y1 - textHeight - 4);
332
+
333
+ ctx.fillRect(textX, textY, textWidth, textHeight);
334
+ ctx.fillStyle = "#ffffff";
335
+ ctx.fillText(label, textX + 8, textY + 5);
336
+ });
337
+ }
338
+
339
+ function extractMeterReading(detections) {
340
+ const digits = detections.filter(
341
+ (item) => item.classIndex >= 2 && item.classIndex <= 11
342
+ );
343
+ const unknown = detections.some((item) => item.classIndex === 12);
344
+
345
+ if (!digits.length) {
346
+ if (unknown) {
347
+ return "Unreadable";
348
+ }
349
+ return "No digits detected";
350
+ }
351
+
352
+ const ordered = digits.sort((a, b) => a.x1 - b.x1);
353
+ return ordered.map((item) => item.label).join("");
354
+ }
355
+
356
+ function computeAverageConfidence(detections) {
357
+ const digits = detections.filter(
358
+ (item) => item.classIndex >= 2 && item.classIndex <= 11
359
+ );
360
+
361
+ if (!digits.length) {
362
+ return 0;
363
+ }
364
+
365
+ const sum = digits.reduce((acc, item) => acc + item.score, 0);
366
+ return sum / digits.length;
367
+ }
368
+
369
+ function updatePredictionUI(prediction) {
370
+ const { detections, reading, averageConfidence } = prediction;
371
+ meterReading.textContent = reading;
372
+ confidence.textContent = averageConfidence
373
+ ? `${(averageConfidence * 100).toFixed(1)}%`
374
+ : "--";
375
+
376
+ if (!detections.length) {
377
+ detectionsList.innerHTML =
378
+ "<p class=\"small-text\">No objects detected in this image.</p>";
379
+ return;
380
+ }
381
+
382
+ detectionsList.innerHTML = detections
383
+ .slice(0, 20)
384
+ .map(
385
+ (item) =>
386
+ `<div class="detection-card"><strong>${item.label}</strong><span>Score: ${(item.score * 100).toFixed(1)}%</span></div>`
387
+ )
388
+ .join("");
389
+ }
390
+
391
+ function clamp(value, min, max) {
392
+ return Math.max(min, Math.min(value, max));
393
+ }
394
+
395
+ loadModel();
style.css ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #08111f;
3
+ --card: rgba(15, 37, 67, 0.96);
4
+ --card-strong: rgba(10, 17, 30, 0.96);
5
+ --border: rgba(255, 255, 255, 0.08);
6
+ --primary: #00b4ff;
7
+ --accent: #22d3ee;
8
+ --text: #f5f9ff;
9
+ --muted: #98adc9;
10
+ --success: #2dd39f;
11
+ --danger: #ff5f7a;
12
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
13
+ }
14
+
15
+ * {
16
+ margin: 0;
17
+ padding: 0;
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ body {
22
+ font-family: Inter, "Segoe UI", sans-serif;
23
+ min-height: 100vh;
24
+ background: radial-gradient(circle at top left, rgba(0, 180, 255, 0.14), transparent 28%),
25
+ radial-gradient(circle at bottom right, rgba(34, 211, 238, 0.12), transparent 28%),
26
+ linear-gradient(180deg, #06111f 0%, #081728 100%);
27
+ color: var(--text);
28
+ }
29
+
30
+ .background {
31
+ position: fixed;
32
+ inset: 0;
33
+ background: radial-gradient(circle at top right, rgba(0, 180, 255, 0.18), transparent 24%),
34
+ radial-gradient(circle at bottom left, rgba(34, 211, 238, 0.18), transparent 24%);
35
+ pointer-events: none;
36
+ z-index: -1;
37
+ }
38
+
39
+ .container {
40
+ width: min(1280px, 94%);
41
+ margin: 0 auto;
42
+ padding: 40px 0 60px;
43
+ }
44
+
45
+ .hero {
46
+ display: flex;
47
+ justify-content: space-between;
48
+ align-items: flex-end;
49
+ gap: 24px;
50
+ margin-bottom: 32px;
51
+ }
52
+
53
+ .hero h1 {
54
+ font-size: clamp(2.4rem, 2.7vw, 3.4rem);
55
+ line-height: 1.05;
56
+ }
57
+
58
+ .hero p {
59
+ max-width: 600px;
60
+ color: var(--muted);
61
+ margin-top: 10px;
62
+ font-size: 1rem;
63
+ line-height: 1.8;
64
+ }
65
+
66
+ .status-group {
67
+ display: flex;
68
+ flex-wrap: wrap;
69
+ gap: 12px;
70
+ align-items: center;
71
+ }
72
+
73
+ .badge {
74
+ display: inline-flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ min-width: 124px;
78
+ padding: 12px 18px;
79
+ border-radius: 999px;
80
+ font-size: 0.95rem;
81
+ font-weight: 700;
82
+ letter-spacing: 0.01em;
83
+ }
84
+
85
+ .loading {
86
+ background: #315b8c;
87
+ }
88
+
89
+ .ready {
90
+ background: var(--success);
91
+ color: #06111f;
92
+ }
93
+
94
+ .error {
95
+ background: rgba(255, 95, 122, 0.18);
96
+ color: var(--danger);
97
+ }
98
+
99
+ .hint {
100
+ display: inline-block;
101
+ color: var(--muted);
102
+ font-size: 0.95rem;
103
+ }
104
+
105
+ .upload-card,
106
+ .result-card,
107
+ .image-card,
108
+ .detections {
109
+ background: var(--card);
110
+ border: 1px solid var(--border);
111
+ border-radius: 28px;
112
+ box-shadow: var(--shadow);
113
+ }
114
+
115
+ .drop-zone {
116
+ height: 260px;
117
+ border: 2px dashed rgba(255, 255, 255, 0.14);
118
+ border-radius: 26px;
119
+ display: flex;
120
+ justify-content: center;
121
+ align-items: center;
122
+ transition: transform 0.25s ease, border-color 0.25s ease, background-color 0.25s ease;
123
+ background: rgba(255, 255, 255, 0.03);
124
+ cursor: pointer;
125
+ }
126
+
127
+ .drop-zone:hover,
128
+ .drop-zone.drag-over {
129
+ border-color: rgba(0, 180, 255, 0.7);
130
+ transform: scale(1.01);
131
+ background: rgba(0, 180, 255, 0.08);
132
+ }
133
+
134
+ .upload-content {
135
+ text-align: center;
136
+ max-width: 380px;
137
+ }
138
+
139
+ .upload-content h2 {
140
+ font-size: 1.6rem;
141
+ margin-bottom: 10px;
142
+ }
143
+
144
+ .upload-content p {
145
+ color: var(--muted);
146
+ margin-bottom: 22px;
147
+ line-height: 1.75;
148
+ }
149
+
150
+ #browseBtn,
151
+ .primary-button {
152
+ border: none;
153
+ border-radius: 16px;
154
+ padding: 14px 26px;
155
+ font-size: 1rem;
156
+ font-weight: 700;
157
+ color: #fff;
158
+ background: linear-gradient(135deg, #00b4ff, #0fd4d4);
159
+ cursor: pointer;
160
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
161
+ }
162
+
163
+ #browseBtn:hover,
164
+ .primary-button:hover {
165
+ transform: translateY(-2px);
166
+ box-shadow: 0 18px 32px rgba(0, 180, 255, 0.22);
167
+ }
168
+
169
+ .viewer {
170
+ display: grid;
171
+ grid-template-columns: 2fr 1fr;
172
+ gap: 24px;
173
+ margin-top: 28px;
174
+ }
175
+
176
+ .card-header {
177
+ display: flex;
178
+ justify-content: space-between;
179
+ align-items: center;
180
+ gap: 18px;
181
+ margin-bottom: 22px;
182
+ }
183
+
184
+ .image-card,
185
+ .result-card {
186
+ padding: 26px;
187
+ }
188
+
189
+ .canvas-viewport {
190
+ width: 100%;
191
+ min-height: 420px;
192
+ background: rgba(255, 255, 255, 0.04);
193
+ border-radius: 22px;
194
+ padding: 14px;
195
+ display: grid;
196
+ place-items: center;
197
+ }
198
+
199
+ canvas {
200
+ width: 100%;
201
+ max-width: 100%;
202
+ height: auto;
203
+ border-radius: 18px;
204
+ background: #07101d;
205
+ }
206
+
207
+ .muted {
208
+ color: var(--muted);
209
+ font-size: 0.95rem;
210
+ }
211
+
212
+ .reading,
213
+ .confidence {
214
+ margin-bottom: 22px;
215
+ }
216
+
217
+ .title {
218
+ display: block;
219
+ color: var(--muted);
220
+ margin-bottom: 10px;
221
+ font-size: 0.95rem;
222
+ }
223
+
224
+ #meterReading {
225
+ display: block;
226
+ font-size: 2.8rem;
227
+ font-weight: 800;
228
+ color: var(--accent);
229
+ word-break: break-all;
230
+ }
231
+
232
+ #confidence {
233
+ display: block;
234
+ font-size: 1.4rem;
235
+ font-weight: 700;
236
+ }
237
+
238
+ .prediction-footer {
239
+ margin-top: 16px;
240
+ }
241
+
242
+ .detections {
243
+ margin-top: 32px;
244
+ padding: 26px;
245
+ }
246
+
247
+ .detections h2 {
248
+ margin-bottom: 20px;
249
+ }
250
+
251
+ .detections-grid {
252
+ display: grid;
253
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
254
+ gap: 14px;
255
+ }
256
+
257
+ .detection-card {
258
+ background: rgba(255, 255, 255, 0.05);
259
+ border: 1px solid rgba(255, 255, 255, 0.08);
260
+ border-radius: 16px;
261
+ padding: 16px;
262
+ }
263
+
264
+ .detection-card strong {
265
+ display: block;
266
+ font-size: 1rem;
267
+ margin-bottom: 8px;
268
+ }
269
+
270
+ .small-text {
271
+ color: var(--muted);
272
+ line-height: 1.7;
273
+ }
274
+
275
+ @media (max-width: 980px) {
276
+ .viewer {
277
+ grid-template-columns: 1fr;
278
+ }
279
+
280
+ .hero {
281
+ flex-direction: column;
282
+ align-items: flex-start;
283
+ }
284
+ }
285
+
286
+ @media (max-width: 720px) {
287
+ .container {
288
+ padding: 24px 0 40px;
289
+ }
290
+
291
+ .drop-zone {
292
+ height: auto;
293
+ padding: 40px 18px;
294
+ }
295
+
296
+ .canvas-viewport {
297
+ min-height: 320px;
298
+ }
299
+ }