Spaces:
Sleeping
Sleeping
cyberai-1 commited on
Commit ·
366dac3
1
Parent(s): 49fb46d
Improve prediction upload and dashboard charts
Browse files- app.py +145 -34
- static/css/app.css +151 -6
- static/js/app.js +28 -0
- templates/base.html +1 -0
- templates/dashboard.html +44 -0
- templates/predict.html +24 -4
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 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 541 |
-
|
| 542 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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 |
-
<
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 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 %}
|