cyberai-1 commited on
Commit
366dac3
·
1 Parent(s): 49fb46d

Improve prediction upload and dashboard charts

Browse files
app.py CHANGED
@@ -1,7 +1,10 @@
1
  import os
2
  import sqlite3
 
3
  from datetime import datetime
4
  from pathlib import Path
 
 
5
  from uuid import uuid4
6
 
7
  import numpy as np
@@ -22,6 +25,11 @@ UPLOAD_DIR = BASE_DIR / "static" / "uploads"
22
  DATABASE_PATH = BASE_DIR / "instance" / "traffic_signs.sqlite3"
23
  MODEL_PATH = BASE_DIR / "traffic_classifier.h5"
24
  ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
 
 
 
 
 
25
 
26
  TRAFFIC_SIGN_CLASSES = [
27
  "Speed limit (20km/h)",
@@ -158,6 +166,64 @@ def allowed_file(filename):
158
  return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
159
 
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  def prepare_image(path):
162
  image = Image.open(path).convert("RGB")
163
  image = image.resize((30, 30))
@@ -256,39 +322,27 @@ def predict():
256
  prediction = None
257
  if request.method == "POST":
258
  file = request.files.get("image")
259
- if not file or file.filename == "":
260
- flash("Please choose a traffic sign image.", "warning")
261
- elif not allowed_file(file.filename):
262
- flash("Upload a PNG, JPG, JPEG, or WEBP image.", "danger")
263
- else:
264
- original = secure_filename(file.filename)
265
- filename = f"{uuid4().hex}_{original}"
266
- save_path = UPLOAD_DIR / filename
267
- file.save(save_path)
268
- label, confidence = predict_sign(save_path)
269
- image_path = f"uploads/{filename}"
270
- with get_db() as conn:
271
- cursor = conn.execute(
272
- """
273
- INSERT INTO predictions
274
- (user_id, image_path, predicted_class, confidence, is_correct, created_at)
275
- VALUES (?, ?, ?, ?, NULL, ?)
276
- """,
277
- (
278
- session["user_id"],
279
- image_path,
280
- label,
281
- confidence,
282
- datetime.utcnow().isoformat(),
283
- ),
284
- )
285
- prediction_id = cursor.lastrowid
286
- prediction = {
287
- "id": prediction_id,
288
- "image_path": image_path,
289
- "predicted_class": label,
290
- "confidence": confidence,
291
- }
292
  if model_error:
293
  flash(model_error, "warning")
294
 
@@ -296,6 +350,32 @@ def predict():
296
  return render_template("predict.html", prediction=prediction, history=history, model_error=model_error)
297
 
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  @app.post("/feedback/<int:prediction_id>")
300
  @login_required
301
  def feedback(prediction_id):
@@ -330,7 +410,38 @@ def dashboard():
330
  "incorrect": len(reviewed) - len(correct),
331
  "accuracy": round((len(correct) / len(reviewed)) * 100, 1) if reviewed else 0,
332
  }
333
- return render_template("dashboard.html", history=history, stats=stats)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
 
336
  def get_user_history(limit=None):
 
1
  import os
2
  import sqlite3
3
+ from collections import Counter
4
  from datetime import datetime
5
  from pathlib import Path
6
+ from urllib.parse import urlparse
7
+ from urllib.request import Request, urlopen
8
  from uuid import uuid4
9
 
10
  import numpy as np
 
25
  DATABASE_PATH = BASE_DIR / "instance" / "traffic_signs.sqlite3"
26
  MODEL_PATH = BASE_DIR / "traffic_classifier.h5"
27
  ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
28
+ URL_CONTENT_TYPES = {
29
+ "image/jpeg": "jpg",
30
+ "image/png": "png",
31
+ "image/webp": "webp",
32
+ }
33
 
34
  TRAFFIC_SIGN_CLASSES = [
35
  "Speed limit (20km/h)",
 
166
  return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
167
 
168
 
169
+ def extension_from_url(url, content_type):
170
+ path = urlparse(url).path
171
+ if "." in path:
172
+ extension = path.rsplit(".", 1)[1].lower()
173
+ if extension in ALLOWED_EXTENSIONS:
174
+ return extension
175
+ return URL_CONTENT_TYPES.get((content_type or "").split(";")[0].lower())
176
+
177
+
178
+ def save_remote_image(image_url):
179
+ parsed = urlparse(image_url)
180
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
181
+ raise ValueError("Please provide a valid HTTP or HTTPS image link.")
182
+
183
+ request_data = Request(image_url, headers={"User-Agent": "TrafficSignClassifier/1.0"})
184
+ with urlopen(request_data, timeout=10) as response:
185
+ content_type = response.headers.get("Content-Type", "")
186
+ extension = extension_from_url(image_url, content_type)
187
+ if not extension:
188
+ raise ValueError("The link must point to a PNG, JPG, JPEG, or WEBP image.")
189
+
190
+ filename = f"{uuid4().hex}_remote.{extension}"
191
+ save_path = UPLOAD_DIR / filename
192
+ max_bytes = app.config["MAX_CONTENT_LENGTH"]
193
+ total = 0
194
+ with save_path.open("wb") as output:
195
+ while True:
196
+ chunk = response.read(1024 * 64)
197
+ if not chunk:
198
+ break
199
+ total += len(chunk)
200
+ if total > max_bytes:
201
+ save_path.unlink(missing_ok=True)
202
+ raise ValueError("Remote image is too large. Maximum size is 8 MB.")
203
+ output.write(chunk)
204
+
205
+ try:
206
+ Image.open(save_path).verify()
207
+ except Exception as exc:
208
+ save_path.unlink(missing_ok=True)
209
+ raise ValueError("The remote file is not a readable image.") from exc
210
+
211
+ return save_path, f"uploads/{filename}"
212
+
213
+
214
+ def save_uploaded_image(file):
215
+ original = secure_filename(file.filename)
216
+ filename = f"{uuid4().hex}_{original}"
217
+ save_path = UPLOAD_DIR / filename
218
+ file.save(save_path)
219
+ try:
220
+ Image.open(save_path).verify()
221
+ except Exception as exc:
222
+ save_path.unlink(missing_ok=True)
223
+ raise ValueError("The uploaded file is not a readable image.") from exc
224
+ return save_path, f"uploads/{filename}"
225
+
226
+
227
  def prepare_image(path):
228
  image = Image.open(path).convert("RGB")
229
  image = image.resize((30, 30))
 
322
  prediction = None
323
  if request.method == "POST":
324
  file = request.files.get("image")
325
+ image_url = request.form.get("image_url", "").strip()
326
+ save_path = None
327
+ image_path = None
328
+
329
+ try:
330
+ if file and file.filename:
331
+ if not allowed_file(file.filename):
332
+ flash("Upload a PNG, JPG, JPEG, or WEBP image.", "danger")
333
+ else:
334
+ save_path, image_path = save_uploaded_image(file)
335
+ elif image_url:
336
+ save_path, image_path = save_remote_image(image_url)
337
+ else:
338
+ flash("Please choose a traffic sign image or paste an image link.", "warning")
339
+ except ValueError as exc:
340
+ flash(str(exc), "danger")
341
+ except Exception:
342
+ flash("The remote image could not be downloaded. Please try another link.", "danger")
343
+
344
+ if save_path and image_path:
345
+ prediction = create_prediction(save_path, image_path)
 
 
 
 
 
 
 
 
 
 
 
 
346
  if model_error:
347
  flash(model_error, "warning")
348
 
 
350
  return render_template("predict.html", prediction=prediction, history=history, model_error=model_error)
351
 
352
 
353
+ def create_prediction(save_path, image_path):
354
+ label, confidence = predict_sign(save_path)
355
+ with get_db() as conn:
356
+ cursor = conn.execute(
357
+ """
358
+ INSERT INTO predictions
359
+ (user_id, image_path, predicted_class, confidence, is_correct, created_at)
360
+ VALUES (?, ?, ?, ?, NULL, ?)
361
+ """,
362
+ (
363
+ session["user_id"],
364
+ image_path,
365
+ label,
366
+ confidence,
367
+ datetime.utcnow().isoformat(),
368
+ ),
369
+ )
370
+ prediction_id = cursor.lastrowid
371
+ return {
372
+ "id": prediction_id,
373
+ "image_path": image_path,
374
+ "predicted_class": label,
375
+ "confidence": confidence,
376
+ }
377
+
378
+
379
  @app.post("/feedback/<int:prediction_id>")
380
  @login_required
381
  def feedback(prediction_id):
 
410
  "incorrect": len(reviewed) - len(correct),
411
  "accuracy": round((len(correct) / len(reviewed)) * 100, 1) if reviewed else 0,
412
  }
413
+ charts = build_dashboard_charts(history, stats)
414
+ return render_template("dashboard.html", history=history, stats=stats, charts=charts)
415
+
416
+
417
+ def build_dashboard_charts(history, stats):
418
+ class_counts = Counter(row["predicted_class"] for row in history)
419
+ max_class_count = max(class_counts.values(), default=1)
420
+ class_chart = [
421
+ {
422
+ "label": label,
423
+ "count": count,
424
+ "percent": round((count / max_class_count) * 100, 1),
425
+ }
426
+ for label, count in class_counts.most_common(8)
427
+ ]
428
+
429
+ feedback_chart = [
430
+ {"label": "True predictions", "count": stats["correct"], "percent": percent_of(stats["correct"], stats["total"])},
431
+ {"label": "False predictions", "count": stats["incorrect"], "percent": percent_of(stats["incorrect"], stats["total"])},
432
+ {
433
+ "label": "Pending review",
434
+ "count": stats["total"] - stats["reviewed"],
435
+ "percent": percent_of(stats["total"] - stats["reviewed"], stats["total"]),
436
+ },
437
+ ]
438
+ return {"class_chart": class_chart, "feedback_chart": feedback_chart}
439
+
440
+
441
+ def percent_of(value, total):
442
+ if not total:
443
+ return 0
444
+ return round((value / total) * 100, 1)
445
 
446
 
447
  def get_user_history(limit=None):
static/css/app.css CHANGED
@@ -416,12 +416,14 @@ small {
416
 
417
  .cyber-field input,
418
  .upload-box,
 
419
  table {
420
  width: 100%;
421
  }
422
 
423
  .cyber-field input,
424
- .upload-box {
 
425
  border: 1px solid var(--line);
426
  border-radius: 8px;
427
  padding: 14px;
@@ -481,12 +483,62 @@ table {
481
  max-width: 520px;
482
  }
483
 
484
- .upload-box {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  display: grid;
486
  gap: 10px;
 
 
 
 
487
  color: var(--muted);
488
  }
489
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  .result-image {
491
  width: 100%;
492
  max-height: 280px;
@@ -535,11 +587,16 @@ table {
535
 
536
  .section-heading {
537
  justify-content: space-between;
 
538
  }
539
 
540
- .section-heading a {
541
- color: var(--green);
542
- font-weight: 800;
 
 
 
 
543
  }
544
 
545
  .history-grid {
@@ -577,6 +634,93 @@ table {
577
  font-size: 2rem;
578
  }
579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  .table-panel {
581
  overflow-x: auto;
582
  }
@@ -643,7 +787,8 @@ th {
643
  .hero,
644
  .page-grid,
645
  .feature-band,
646
- .stats-grid {
 
647
  grid-template-columns: 1fr;
648
  }
649
 
 
416
 
417
  .cyber-field input,
418
  .upload-box,
419
+ .link-field input,
420
  table {
421
  width: 100%;
422
  }
423
 
424
  .cyber-field input,
425
+ .upload-box,
426
+ .link-field input {
427
  border: 1px solid var(--line);
428
  border-radius: 8px;
429
  padding: 14px;
 
483
  max-width: 520px;
484
  }
485
 
486
+ .upload-box,
487
+ .link-field {
488
+ display: grid;
489
+ gap: 10px;
490
+ color: var(--muted);
491
+ }
492
+
493
+ .upload-box span,
494
+ .link-field span,
495
+ .preview-card span {
496
+ font-size: 0.82rem;
497
+ font-weight: 800;
498
+ letter-spacing: 1px;
499
+ text-transform: uppercase;
500
+ }
501
+
502
+ .upload-divider {
503
+ display: flex;
504
+ align-items: center;
505
+ gap: 12px;
506
+ color: var(--muted);
507
+ font-size: 0.78rem;
508
+ font-weight: 900;
509
+ text-transform: uppercase;
510
+ }
511
+
512
+ .upload-divider::before,
513
+ .upload-divider::after {
514
+ content: "";
515
+ flex: 1;
516
+ height: 1px;
517
+ background: rgba(255, 255, 255, 0.12);
518
+ }
519
+
520
+ .preview-card {
521
  display: grid;
522
  gap: 10px;
523
+ border: 1px solid rgba(255, 255, 255, 0.1);
524
+ border-radius: 8px;
525
+ padding: 12px;
526
+ background: rgba(255, 255, 255, 0.04);
527
  color: var(--muted);
528
  }
529
 
530
+ .preview-card[hidden] {
531
+ display: none;
532
+ }
533
+
534
+ .preview-card img {
535
+ width: 100%;
536
+ max-height: 260px;
537
+ object-fit: contain;
538
+ border-radius: 8px;
539
+ background: rgba(0, 0, 0, 0.24);
540
+ }
541
+
542
  .result-image {
543
  width: 100%;
544
  max-height: 280px;
 
587
 
588
  .section-heading {
589
  justify-content: space-between;
590
+ margin-bottom: 16px;
591
  }
592
 
593
+ .section-heading h2 {
594
+ margin-bottom: 0;
595
+ }
596
+
597
+ .dashboard-link {
598
+ align-self: center;
599
+ white-space: nowrap;
600
  }
601
 
602
  .history-grid {
 
634
  font-size: 2rem;
635
  }
636
 
637
+ .charts-grid {
638
+ display: grid;
639
+ grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
640
+ gap: 16px;
641
+ margin-top: 24px;
642
+ }
643
+
644
+ .chart-panel {
645
+ border: 1px solid var(--line);
646
+ border-radius: 8px;
647
+ padding: 20px;
648
+ background: var(--panel);
649
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
650
+ }
651
+
652
+ .chart-heading,
653
+ .bar-meta {
654
+ display: flex;
655
+ align-items: center;
656
+ justify-content: space-between;
657
+ gap: 12px;
658
+ }
659
+
660
+ .chart-heading {
661
+ margin-bottom: 18px;
662
+ }
663
+
664
+ .chart-heading h2 {
665
+ margin-bottom: 0;
666
+ }
667
+
668
+ .chart-heading span,
669
+ .bar-meta span {
670
+ color: var(--muted);
671
+ }
672
+
673
+ .bar-list {
674
+ display: grid;
675
+ gap: 14px;
676
+ }
677
+
678
+ .bar-row {
679
+ display: grid;
680
+ gap: 8px;
681
+ }
682
+
683
+ .bar-meta span {
684
+ min-width: 0;
685
+ overflow: hidden;
686
+ text-overflow: ellipsis;
687
+ white-space: nowrap;
688
+ }
689
+
690
+ .bar-meta strong {
691
+ color: var(--text);
692
+ }
693
+
694
+ .bar-track {
695
+ height: 12px;
696
+ overflow: hidden;
697
+ border-radius: 999px;
698
+ background: rgba(255, 255, 255, 0.08);
699
+ }
700
+
701
+ .bar-fill {
702
+ display: block;
703
+ height: 100%;
704
+ min-width: 4px;
705
+ border-radius: inherit;
706
+ }
707
+
708
+ .class-fill {
709
+ background: linear-gradient(90deg, var(--green), var(--blue));
710
+ }
711
+
712
+ .fill-1 {
713
+ background: var(--green);
714
+ }
715
+
716
+ .fill-2 {
717
+ background: var(--pink);
718
+ }
719
+
720
+ .fill-3 {
721
+ background: var(--amber);
722
+ }
723
+
724
  .table-panel {
725
  overflow-x: auto;
726
  }
 
787
  .hero,
788
  .page-grid,
789
  .feature-band,
790
+ .stats-grid,
791
+ .charts-grid {
792
  grid-template-columns: 1fr;
793
  }
794
 
static/js/app.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const imageInput = document.querySelector("#imageInput");
2
+ const imageUrlInput = document.querySelector("#imageUrlInput");
3
+ const previewCard = document.querySelector("#previewCard");
4
+ const imagePreview = document.querySelector("#imagePreview");
5
+
6
+ function showPreview(src) {
7
+ if (!previewCard || !imagePreview || !src) return;
8
+ imagePreview.src = src;
9
+ previewCard.hidden = false;
10
+ }
11
+
12
+ if (imageInput) {
13
+ imageInput.addEventListener("change", () => {
14
+ const file = imageInput.files && imageInput.files[0];
15
+ if (!file) return;
16
+ showPreview(URL.createObjectURL(file));
17
+ if (imageUrlInput) imageUrlInput.value = "";
18
+ });
19
+ }
20
+
21
+ if (imageUrlInput) {
22
+ imageUrlInput.addEventListener("input", () => {
23
+ const value = imageUrlInput.value.trim();
24
+ if (!value) return;
25
+ showPreview(value);
26
+ if (imageInput) imageInput.value = "";
27
+ });
28
+ }
templates/base.html CHANGED
@@ -42,5 +42,6 @@
42
 
43
  {% block content %}{% endblock %}
44
  </main>
 
45
  </body>
46
  </html>
 
42
 
43
  {% block content %}{% endblock %}
44
  </main>
45
+ {% block scripts %}{% endblock %}
46
  </body>
47
  </html>
templates/dashboard.html CHANGED
@@ -20,6 +20,50 @@
20
  <article><span>Feedback accuracy</span><strong>{{ stats.accuracy }}%</strong></article>
21
  </section>
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  <section class="table-panel">
24
  <table>
25
  <thead>
 
20
  <article><span>Feedback accuracy</span><strong>{{ stats.accuracy }}%</strong></article>
21
  </section>
22
 
23
+ <section class="charts-grid">
24
+ <article class="chart-panel">
25
+ <div class="chart-heading">
26
+ <h2>Predictions by class</h2>
27
+ <span>Top {{ charts.class_chart|length }}</span>
28
+ </div>
29
+ <div class="bar-list">
30
+ {% for item in charts.class_chart %}
31
+ <div class="bar-row">
32
+ <div class="bar-meta">
33
+ <span>{{ item.label }}</span>
34
+ <strong>{{ item.count }}</strong>
35
+ </div>
36
+ <div class="bar-track">
37
+ <span class="bar-fill class-fill" style="width: {{ item.percent }}%"></span>
38
+ </div>
39
+ </div>
40
+ {% else %}
41
+ <p class="empty">No class data yet.</p>
42
+ {% endfor %}
43
+ </div>
44
+ </article>
45
+
46
+ <article class="chart-panel">
47
+ <div class="chart-heading">
48
+ <h2>Feedback summary</h2>
49
+ <span>{{ stats.reviewed }} reviewed</span>
50
+ </div>
51
+ <div class="bar-list">
52
+ {% for item in charts.feedback_chart %}
53
+ <div class="bar-row">
54
+ <div class="bar-meta">
55
+ <span>{{ item.label }}</span>
56
+ <strong>{{ item.count }}</strong>
57
+ </div>
58
+ <div class="bar-track">
59
+ <span class="bar-fill feedback-fill fill-{{ loop.index }}" style="width: {{ item.percent }}%"></span>
60
+ </div>
61
+ </div>
62
+ {% endfor %}
63
+ </div>
64
+ </article>
65
+ </section>
66
+
67
  <section class="table-panel">
68
  <table>
69
  <thead>
templates/predict.html CHANGED
@@ -15,9 +15,22 @@
15
 
16
  <form class="upload-form" method="post" enctype="multipart/form-data">
17
  <label class="upload-box">
18
- <span>Choose image</span>
19
- <input type="file" name="image" accept="image/png,image/jpeg,image/webp" required>
20
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  <button class="button primary" type="submit">Classify sign</button>
22
  </form>
23
  </div>
@@ -44,8 +57,11 @@
44
 
45
  <section class="history-strip">
46
  <div class="section-heading">
47
- <h2>Recent history</h2>
48
- <a href="{{ url_for('dashboard') }}">Open dashboard</a>
 
 
 
49
  </div>
50
  <div class="history-grid">
51
  {% for row in history %}
@@ -60,3 +76,7 @@
60
  </div>
61
  </section>
62
  {% endblock %}
 
 
 
 
 
15
 
16
  <form class="upload-form" method="post" enctype="multipart/form-data">
17
  <label class="upload-box">
18
+ <span>Choose image from your device</span>
19
+ <input id="imageInput" type="file" name="image" accept="image/png,image/jpeg,image/webp">
20
  </label>
21
+
22
+ <div class="upload-divider"><span>or</span></div>
23
+
24
+ <label class="link-field">
25
+ <span>Paste image link</span>
26
+ <input id="imageUrlInput" type="url" name="image_url" placeholder="https://example.com/traffic-sign.jpg">
27
+ </label>
28
+
29
+ <div class="preview-card" id="previewCard" hidden>
30
+ <span>Preview before classification</span>
31
+ <img id="imagePreview" alt="Selected traffic sign preview">
32
+ </div>
33
+
34
  <button class="button primary" type="submit">Classify sign</button>
35
  </form>
36
  </div>
 
57
 
58
  <section class="history-strip">
59
  <div class="section-heading">
60
+ <div>
61
+ <p class="eyebrow">Historique</p>
62
+ <h2>Recent history</h2>
63
+ </div>
64
+ <a class="button secondary dashboard-link" href="{{ url_for('dashboard') }}">Open dashboard</a>
65
  </div>
66
  <div class="history-grid">
67
  {% for row in history %}
 
76
  </div>
77
  </section>
78
  {% endblock %}
79
+
80
+ {% block scripts %}
81
+ <script src="{{ url_for('static', filename='js/app.js') }}"></script>
82
+ {% endblock %}