Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>TrafficSense β Road Traffic Monitor</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700;900&family=Barlow:wght@300;400;500&display=swap" rel="stylesheet"/> | |
| <style> | |
| :root { | |
| --bg: #080c10; | |
| --surface: #0d1218; | |
| --panel: #111820; | |
| --border: #1e2d3d; | |
| --accent: #00e5ff; | |
| --amber: #ffb300; | |
| --green: #00ff88; | |
| --red: #ff3860; | |
| --text: #c8d8e8; | |
| --dim: #4a6070; | |
| --mono: 'Share Tech Mono', monospace; | |
| --head: 'Barlow Condensed', sans-serif; | |
| --body: 'Barlow', sans-serif; | |
| } | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { height: 100%; overflow: hidden; } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: var(--body); | |
| min-height: 100vh; | |
| font-size: 20px; /* Increased base font size */ | |
| } | |
| /* Increase font size for buttons, labels, etc. */ | |
| .btn, .btn-primary, .btn-secondary { | |
| font-size: 20px; | |
| } | |
| .s-label, .field label, .nav-btn, .view-tab, .up-hint, .up-name, .counter-list, .cnt-card, .cnt-left, .cnt-val, .total-label, .total-val, .frame-info, .sc-label, .sc-value, .sc-unit, .bar-label, .bar-count, .log-name, .log-meta, .log-dl, #toast { | |
| font-size: 18px ; | |
| } | |
| .upload-zone { | |
| font-size: 20px; | |
| } | |
| /* ββ HEADER ββ */ | |
| header { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 0 28px; height: 56px; | |
| border-bottom: 1px solid var(--border); | |
| background: var(--surface); | |
| position: sticky; top: 0; z-index: 100; | |
| } | |
| .logo { display: flex; align-items: center; gap: 12px; } | |
| .logo-box { | |
| width: 30px; height: 30px; border: 2px solid var(--accent); | |
| display: grid; place-items: center; | |
| font-family: var(--mono); font-size: 12px; color: var(--accent); | |
| } | |
| .logo-text { font-family: var(--head); font-weight: 700; font-size: 20px; letter-spacing: 2px; } | |
| .logo-text span { color: var(--accent); } | |
| .header-right { display: flex; align-items: center; gap: 24px; } | |
| nav { display: flex; gap: 2px; } | |
| .nav-btn { | |
| background: none; border: none; cursor: pointer; | |
| font-family: var(--head); font-weight: 600; font-size: 12px; | |
| letter-spacing: 1.5px; color: var(--dim); padding: 6px 14px; | |
| border-bottom: 2px solid transparent; transition: all .2s; | |
| text-transform: uppercase; | |
| } | |
| .nav-btn.active, .nav-btn:hover { color: var(--accent); border-color: var(--accent); } | |
| .status-badge { | |
| font-family: var(--mono); font-size: 11px; color: var(--dim); | |
| display: flex; align-items: center; gap: 8px; | |
| } | |
| .dot { | |
| width: 7px; height: 7px; border-radius: 50%; background: var(--dim); | |
| } | |
| .dot.live { background: var(--green); box-shadow: 0 0 8px var(--green); animation: blink 1.4s ease infinite; } | |
| .dot.proc { background: var(--amber); box-shadow: 0 0 8px var(--amber); animation: blink .8s ease infinite; } | |
| .dot.err { background: var(--red); } | |
| @keyframes blink { 0%,100%{opacity:1}50%{opacity:.3} } | |
| /* ββ LAYOUT ββ */ | |
| main { display: flex; width: 100vw; height: calc(100vh - 56px); overflow: hidden; } | |
| /* ββ SIDEBAR ββ */ | |
| aside { | |
| width: clamp(220px, 22vw, 315px); min-width: 220px; | |
| background: var(--surface); | |
| display: flex; flex-direction: column; | |
| overflow-y: auto; | |
| } | |
| .left-sidebar { border-right: 1px solid var(--border); } | |
| .right-sidebar { border-left: 1px solid var(--border); } | |
| .right-sidebar .s-sect { padding: 14px 16px; } | |
| .right-sidebar .s-label { margin-bottom: 8px; } | |
| .right-sidebar .btn { padding: 8px 10px; font-size: 18px; } | |
| .right-sidebar .btn-secondary { margin-top: 5px; } | |
| .right-sidebar .prog-bar { margin-top: 7px; } | |
| .right-sidebar .prog-label { margin-top: 3px; } | |
| .right-sidebar .total-banner { padding: 7px 10px; margin-bottom: 7px; } | |
| .right-sidebar .total-val { font-size: 28px; } | |
| .right-sidebar .counter-list { gap: 5px; } | |
| .right-sidebar .cnt-card { padding: 7px 10px; } | |
| .right-sidebar .cnt-val { font-size: 22px; } | |
| .right-sidebar .frame-info { line-height: 1.65; } | |
| .s-sect { padding: 14px 18px; border-bottom: 1px solid var(--border); } | |
| .s-label { | |
| font-family: var(--mono); font-size: 14px; letter-spacing: 2px; | |
| color: var(--dim); text-transform: uppercase; margin-bottom: 12px; font-weight: 600; | |
| } | |
| /* Upload */ | |
| .source-panel { | |
| border: 1px solid rgba(0,229,255,.35); | |
| background: rgba(0,229,255,.045); | |
| box-shadow: inset 0 0 0 1px rgba(0,229,255,.06), 0 0 24px rgba(0,229,255,.06); | |
| } | |
| .source-panel .s-label { | |
| color: var(--accent); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .source-pill { | |
| font-family: var(--mono); | |
| font-size: 10px; | |
| letter-spacing: 1px; | |
| color: var(--bg); | |
| background: var(--accent); | |
| padding: 3px 7px; | |
| border-radius: 2px; | |
| } | |
| .source-choice { | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| color: var(--text); | |
| margin: 12px 0 7px; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| } | |
| .upload-zone { | |
| border: 2px dashed rgba(0,229,255,.55); border-radius: 3px; | |
| background: rgba(8,12,16,.72); | |
| padding: 4px 6px; text-align: center; cursor: pointer; | |
| transition: all .2s; position: relative; | |
| } | |
| .upload-zone:hover, .upload-zone.drag { border-color: var(--accent); background: rgba(0,229,255,.09); } | |
| .upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; } | |
| .up-icon { font-size: 20px; margin-bottom: 2px; } | |
| .up-hint { font-size: 12px; color: var(--dim); line-height: 1.15; } | |
| .up-hint b { color: var(--text); } | |
| .up-name { font-family: var(--mono); font-size: 11px; color: var(--accent); margin-top: 2px; word-break: break-all; } | |
| .webcam-source { | |
| border: 1px solid rgba(0,255,136,.45); | |
| background: rgba(0,255,136,.055); | |
| padding: 10px; | |
| margin-top: 8px; | |
| border-radius: 3px; | |
| } | |
| .webcam-source .btn { | |
| margin-top: 0; | |
| color: var(--green); | |
| border-color: rgba(0,255,136,.55); | |
| background: rgba(0,255,136,.04); | |
| } | |
| .webcam-source .btn:hover { | |
| color: var(--bg); | |
| background: var(--green); | |
| border-color: var(--green); | |
| box-shadow: 0 0 18px rgba(0,255,136,.25); | |
| text-decoration: none; | |
| } | |
| /* Upload progress bar */ | |
| #uploadProgBar { | |
| width: 100%; | |
| height: 8px; | |
| background: var(--border); | |
| border-radius: 3px; | |
| margin-top: 10px; | |
| overflow: hidden; | |
| display: none; | |
| } | |
| #uploadProgFill { | |
| height: 100%; | |
| width: 0%; | |
| background: var(--accent); | |
| transition: width .3s; | |
| } | |
| #uploadProgLabel { | |
| font-family: var(--mono); | |
| font-size: 14px; | |
| color: var(--dim); | |
| margin-top: 4px; | |
| text-align: center; | |
| display: none; | |
| } | |
| /* Fields */ | |
| .field { margin-bottom: 12px; } | |
| .field label { display: block; font-family: var(--mono); font-size: 13px; color: var(--dim); letter-spacing: 1px; margin-bottom: 5px; font-weight: 600; } | |
| .field input, .field select { | |
| width: 100%; background: var(--panel); border: 1px solid var(--border); | |
| color: var(--text); font-family: var(--mono); font-size: 14px; | |
| padding: 7px 10px; outline: none; border-radius: 2px; transition: border-color .2s; | |
| } | |
| .field input:focus, .field select:focus { border-color: var(--accent); } | |
| .last-config-field { margin-bottom: 0; } | |
| .source-divider { | |
| font-family: var(--mono); font-size: 11px; color: var(--dim); | |
| text-align: center; margin: 10px 0; letter-spacing: 1px; | |
| } | |
| /* Class chips */ | |
| .class-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; } | |
| .cls-chip { | |
| display: flex; align-items: center; gap: 7px; | |
| background: var(--panel); border: 1px solid var(--border); | |
| padding: 6px 9px; cursor: pointer; transition: all .2s; border-radius: 2px; | |
| } | |
| .cls-chip input { display: none; } | |
| .cls-chip.on { border-color: var(--accent); background: rgba(0,229,255,.07); } | |
| .cls-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } | |
| .cls-name { font-size: 13px; font-family: var(--mono); } | |
| /* Buttons */ | |
| .btn { | |
| display: block; width: 100%; | |
| font-family: var(--head); font-weight: 700; font-size: 20px; | |
| letter-spacing: 2px; text-transform: uppercase; | |
| padding: 11px; border: none; cursor: pointer; | |
| transition: all .2s; border-radius: 2px; | |
| } | |
| .btn-primary { background: var(--accent); color: var(--bg); } | |
| .btn-primary:hover { background: #33eeff; box-shadow: 0 0 20px rgba(0,229,255,.35); } | |
| .btn-primary:disabled { background: var(--dim); cursor: not-allowed; box-shadow: none; } | |
| .btn-secondary { | |
| background: transparent; color: var(--dim); | |
| border: 1px solid var(--border); margin-top: 7px; | |
| } | |
| .btn-secondary:hover { border-color: var(--red); color: var(--red); } | |
| /* Progress */ | |
| .prog-bar { height: 3px; background: var(--border); margin-top: 10px; border-radius: 2px; overflow: hidden; } | |
| .prog-fill { height: 100%; background: var(--accent); transition: width .4s; width: 0; } | |
| .prog-label { font-family: var(--mono); font-size: 13px; color: var(--dim); margin-top: 5px; text-align: center; } | |
| /* Counter cards */ | |
| .counter-list { display: flex; flex-direction: column; gap: 8px; } | |
| .cnt-card { | |
| display: flex; align-items: center; justify-content: space-between; | |
| background: rgba(13,18,24,0.8); border: 2px solid var(--border); | |
| padding: 14px 16px; border-radius: 3px; | |
| transition: all .2s; | |
| } | |
| .cnt-card:hover { border-color: var(--accent); background: rgba(0,229,255,0.05); } | |
| .cnt-left { display: flex; align-items: center; gap: 10px; font-size: 15px; font-family: var(--mono); font-weight: 500; } | |
| .cnt-val { font-family: var(--mono); font-size: 28px; color: var(--accent); font-weight: bold; min-width: 40px; text-align: right; } | |
| /* Total unique banner */ | |
| .total-banner { | |
| display: flex; align-items: center; justify-content: space-between; | |
| background: rgba(0,229,255,.07); border: 1px solid rgba(0,229,255,.25); | |
| padding: 10px 14px; border-radius: 2px; margin-bottom: 10px; | |
| } | |
| .total-label { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 1px; } | |
| .total-val { font-family: var(--head); font-size: 34px; font-weight: 900; color: var(--accent); } | |
| /* Frame info */ | |
| .frame-info { font-family: var(--mono); font-size: 15px; color: var(--dim); line-height: 2.1; } | |
| .frame-info .fi-val { color: var(--text); } | |
| /* ββ CONTENT ββ */ | |
| .content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; } | |
| .view-tabs { | |
| display: flex; background: var(--surface); | |
| border-bottom: 1px solid var(--border); padding: 0 20px; | |
| } | |
| .view-tab { | |
| font-family: var(--head); font-weight: 600; font-size: 13px; | |
| letter-spacing: 1.5px; color: var(--dim); padding: 10px 18px; | |
| border: none; background: none; cursor: pointer; | |
| border-bottom: 2px solid transparent; | |
| text-transform: uppercase; transition: all .2s; | |
| } | |
| .view-tab.active { color: var(--accent); border-color: var(--accent); } | |
| /* ββ VIDEO PANEL ββ */ | |
| #view-video { | |
| min-width: 0; | |
| flex: 1; display: flex; align-items: center; justify-content: center; | |
| background: #030608; overflow: hidden; position: relative; | |
| } | |
| .no-signal { | |
| display: flex; flex-direction: column; align-items: center; gap: 14px; opacity: .35; | |
| } | |
| .no-signal-icon { font-size: 44px; } | |
| .no-signal-txt { font-family: var(--mono); font-size: 12px; letter-spacing: 2px; } | |
| .no-signal-sub { font-family: var(--mono); font-size: 10px; color: var(--dim); } | |
| #liveCanvas { | |
| max-width: 100%; max-height: calc(100vh - 130px); | |
| display: none; border: 1px solid var(--border); | |
| } | |
| /* Buffer indicator */ | |
| #bufferInfo { | |
| position: absolute; top: 10px; right: 14px; | |
| font-family: var(--mono); font-size: 10px; color: var(--dim); | |
| background: rgba(0,0,0,.55); padding: 4px 8px; border-radius: 2px; | |
| display: none; | |
| } | |
| /* ββ DASHBOARD PANEL ββ */ | |
| #view-dashboard { flex: 1; overflow-y: auto; padding: 22px; display: none; } | |
| .dash-toolbar { | |
| display: flex; justify-content: flex-end; margin-bottom: 14px; | |
| } | |
| .dash-toolbar .field { width: min(260px, 100%); margin-bottom: 0; } | |
| .dash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin: 0 auto 22px; justify-content: center; } | |
| .stat-card { background: var(--surface); border: 1px solid var(--border); padding: 18px; border-radius: 2px; } | |
| .sc-label { font-family: var(--mono); font-size: 10px; color: var(--dim); letter-spacing: 1.5px; margin-bottom: 8px; } | |
| .sc-value { font-family: var(--head); font-size: 34px; font-weight: 900; color: var(--accent); } | |
| .sc-unit { font-family: var(--mono); font-size: 11px; color: var(--dim); } | |
| .dashboard-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 34%); gap: 14px; align-items: start; } | |
| .dashboard-main, .dashboard-side { min-width: 0; } | |
| .chart-card { background: var(--surface); border: 1px solid var(--border); padding: 18px; border-radius: 2px; margin-bottom: 14px; } | |
| .chart-card h3 { font-family: var(--head); font-size: 11px; letter-spacing: 2px; color: var(--dim); margin-bottom: 14px; text-transform: uppercase; } | |
| .bar-chart { display: flex; flex-direction: column; gap: 9px; } | |
| .bar-row { display: flex; align-items: center; gap: 10px; } | |
| .bar-label { font-family: var(--mono); font-size: 11px; color: var(--dim); width: 80px; flex-shrink: 0; } | |
| .bar-track { flex: 1; height: 18px; background: var(--panel); border-radius: 2px; overflow: hidden; } | |
| .bar-fill { height: 100%; border-radius: 2px; transition: width .8s ease; } | |
| .bar-count { font-family: var(--mono); font-size: 11px; color: var(--text); width: 34px; text-align: right; } | |
| #timelineCanvas { width: 100%; height: 110px; display: block; } | |
| #heatmapCanvas { width: 100%; aspect-ratio: 3 / 4; min-height: 300px; display: block; background: #0d1218; border: 1px solid rgba(30,45,61,.7); } | |
| .heatmap-meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; font-family: var(--mono); font-size: 11px; color: var(--dim); } | |
| .heatmap-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(86px, 1fr)); gap: 7px 10px; margin-top: 12px; } | |
| .heatmap-legend-item { display: flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 11px; color: var(--dim); min-width: 0; } | |
| .scene-table { width: 100%; border-collapse: collapse; font-size: 12px; } | |
| .scene-table th { font-family: var(--mono); font-size: 10px; letter-spacing: 1px; color: var(--dim); padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); } | |
| .scene-table td { padding: 9px 12px; border-bottom: 1px solid rgba(30,45,61,.5); font-family: var(--mono); } | |
| .scene-table tr:hover td { background: var(--panel); } | |
| .badge { display: inline-block; padding: 2px 8px; font-family: var(--mono); font-size: 10px; border-radius: 2px; background: rgba(0,229,255,.1); color: var(--accent); border: 1px solid rgba(0,229,255,.25); } | |
| /* ββ LOGS PANEL ββ */ | |
| #view-logs { flex: 1; overflow-y: auto; padding: 22px; display: none; } | |
| .log-entry { | |
| background: var(--surface); border: 1px solid var(--border); | |
| padding: 12px 16px; margin-bottom: 7px; border-radius: 2px; | |
| display: flex; align-items: center; justify-content: space-between; | |
| } | |
| .log-entry:hover { border-color: var(--accent); } | |
| .log-name { font-family: var(--mono); font-size: 12px; color: var(--text); } | |
| .log-meta { font-family: var(--mono); font-size: 10px; color: var(--dim); margin-top: 3px; } | |
| .log-dl { color: var(--accent); text-decoration: none; font-family: var(--mono); font-size: 11px; } | |
| @media (max-width: 1100px) { | |
| .dashboard-layout { grid-template-columns: 1fr; } | |
| #heatmapCanvas { aspect-ratio: 16 / 9; min-height: 220px; } | |
| } | |
| .log-dl:hover { text-decoration: underline; } | |
| /* Toast */ | |
| #toast { | |
| position: fixed; bottom: 22px; right: 22px; | |
| background: var(--panel); border: 1px solid var(--border); | |
| color: var(--text); font-family: var(--mono); font-size: 11px; | |
| padding: 11px 18px; border-radius: 2px; | |
| transform: translateY(70px); opacity: 0; | |
| transition: all .3s; z-index: 9000; | |
| } | |
| #toast.show { transform: translateY(0); opacity: 1; } | |
| #toast.ok { border-color: var(--green); color: var(--green); } | |
| #toast.err { border-color: var(--red); color: var(--red); } | |
| ::-webkit-scrollbar { width: 4px; } | |
| ::-webkit-scrollbar-track { background: var(--bg); } | |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } | |
| @media (max-width: 1180px) { | |
| body { font-size: 18px; } | |
| .btn, .btn-primary, .btn-secondary { font-size: 17px; letter-spacing: 1px; } | |
| .s-label, .field label, .nav-btn, .view-tab, .up-hint, .up-name, .counter-list, .cnt-card, .cnt-left, .cnt-val, .total-label, .total-val, .frame-info, .sc-label, .sc-value, .sc-unit, .bar-label, .bar-count, .log-name, .log-meta, .log-dl, #toast { | |
| font-size: 15px ; | |
| } | |
| aside { width: 230px; min-width: 230px; } | |
| .right-sidebar { width: 215px; min-width: 215px; } | |
| .right-sidebar .s-sect { padding: 9px 10px; } | |
| .right-sidebar .s-label { margin-bottom: 5px; } | |
| .right-sidebar .btn { padding: 6px 7px; font-size: 15px; letter-spacing: .8px; } | |
| .right-sidebar .btn-secondary { margin-top: 4px; } | |
| .right-sidebar .total-banner { padding: 5px 7px; margin-bottom: 5px; } | |
| .right-sidebar .total-val { font-size: 23px; } | |
| .right-sidebar .cnt-card { padding: 5px 7px; } | |
| .right-sidebar .cnt-val { font-size: 18px; } | |
| .s-sect { padding: 12px; } | |
| .class-grid { grid-template-columns: 1fr 1fr; gap: 4px; } | |
| .cls-chip { padding: 5px 6px; gap: 5px; } | |
| .view-tabs { padding: 0 12px; } | |
| .view-tab { padding: 9px 12px; } | |
| } | |
| @media (max-height: 760px) and (min-width: 900px) { | |
| header { height: 48px; } | |
| main { height: calc(100vh - 48px); } | |
| .logo-box { width: 26px; height: 26px; } | |
| .s-sect { padding: 7px 10px; } | |
| .s-label { margin-bottom: 5px; letter-spacing: 1px; } | |
| .upload-zone { padding: 6px 8px; } | |
| .up-icon { display: none; } | |
| .up-hint { line-height: 1.2; } | |
| .up-name { margin-top: 4px; } | |
| .field { margin-bottom: 5px; } | |
| .field label { margin-bottom: 3px; } | |
| .field input, .field select { padding: 5px 7px; } | |
| .cls-chip { padding: 4px 6px; } | |
| .btn { padding: 7px; } | |
| .prog-bar { margin-top: 7px; } | |
| .cnt-card { padding: 6px 8px; } | |
| .total-banner { padding: 6px 8px; margin-bottom: 6px; } | |
| .frame-info { line-height: 1.7; } | |
| .right-sidebar .s-sect { padding: 6px 8px; } | |
| .right-sidebar .s-label { margin-bottom: 4px; } | |
| .right-sidebar .btn { padding: 5px 6px; } | |
| .right-sidebar .prog-bar { height: 2px; margin-top: 5px; } | |
| .right-sidebar .prog-label { margin-top: 2px; } | |
| .right-sidebar .total-banner { padding: 4px 6px; margin-bottom: 4px; } | |
| .right-sidebar .counter-list { gap: 4px; } | |
| .right-sidebar .cnt-card { padding: 4px 6px; } | |
| .right-sidebar .frame-info { line-height: 1.45; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo"> | |
| <div class="logo-box">TS</div> | |
| <div class="logo-text">TRAFFIC<span>SENSE</span></div> | |
| </div> | |
| <nav> | |
| <button class="nav-btn active" id="navVideo" onclick="switchTab('video',this)">LIVE</button> | |
| <button class="nav-btn" id="navDashboard" onclick="switchTab('dashboard',this)">DASHBOARD</button> | |
| <button class="nav-btn" id="navLogs" onclick="switchTab('logs',this)">LOGS</button> | |
| </nav> | |
| <div class="header-right"> | |
| <div class="status-badge"> | |
| <div class="dot" id="connDot"></div> | |
| <span id="connLabel">IDLE</span> | |
| </div> | |
| <div style="font-family:var(--mono);font-size:11px;color:var(--dim)" id="clock">--:--:--</div> | |
| </div> | |
| </header> | |
| <main> | |
| <!-- ββ SIDEBAR ββ --> | |
| <aside class="left-sidebar"> | |
| <!-- Upload --> | |
| <div class="s-sect source-panel"> | |
| <div class="s-label">Video Source <span class="source-pill">INPUT</span></div> | |
| <div class="source-choice">1. Video file</div> | |
| <label class="upload-zone" id="uploadZone"> | |
| <input type="file" id="videoFile" accept="video/*"/> | |
| <div class="up-icon">πΉ</div> | |
| <div class="up-hint"><b>Drop video here</b><br>or click to browse</div> | |
| <div class="up-name" id="fileName"></div> | |
| </label> | |
| <div class="source-divider">OR</div> | |
| <div class="source-choice">2. Video URL</div> | |
| <div class="field"> | |
| <input type="url" id="videoUrl" placeholder="https://example.com/video.mp4"/> | |
| </div> | |
| <div class="source-divider">OR</div> | |
| <div class="source-choice">3. Webcam live</div> | |
| <div class="webcam-source"> | |
| <button class="btn btn-secondary" id="webcamBtn" onclick="startWebcam()">β£ START WEBCAM</button> | |
| </div> | |
| <!-- Progress bar for upload --> | |
| <div id="uploadProgBar"><div id="uploadProgFill"></div></div> | |
| <div id="uploadProgLabel"></div> | |
| </div> | |
| <!-- Config --> | |
| <div class="s-sect"> | |
| <div class="field"> | |
| <label>SCENE NAME</label> | |
| <input type="text" id="sceneName" value="Africa_countries"/> | |
| </div> | |
| <div class="field"> | |
| <label>GROUP ID</label> | |
| <input type="text" id="groupId" value="Group_05"/> | |
| </div> | |
| <div class="field"> | |
| <label>MODEL</label> | |
| <select id="modelSel"> | |
| <option value="best.pt" selected >YOLOv11 - Fine-tuned</option> | |
| <option value="yolov8n.pt">YOLOv8n β nano</option> | |
| <option value="yolov8s.pt">YOLOv8s β small</option> | |
| <option value="yolov8m.pt">YOLOv8m β medium</option> | |
| <option value="yolov8l.pt">YOLOv8l β large</option> | |
| <option value="yolo11n.pt">YOLOv11n β nano</option> | |
| <option value="yolo11s.pt">YOLOv11s β small</option> | |
| <option value="yolo11x.pt">YOLOv11x</option> | |
| </select> | |
| </div> | |
| <div class="field last-config-field"> | |
| <label>CONFIDENCE</label> | |
| <input type="number" id="confVal" value="0.5" min="0.1" max="0.95" step="0.05"/> | |
| </div> | |
| </div> | |
| <!-- Classes --> | |
| <!-- <div class="s-sect"> | |
| <div class="s-label">Track Classes</div> | |
| <div class="class-grid" id="classGrid"></div> | |
| </div> --> | |
| <!-- Watch Video Modal --> | |
| <div id="videoModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.95);z-index:10000;align-items:center;justify-content:center;flex-direction:column"> | |
| <div style="position:relative;width:90%;max-width:900px;height:80vh;display:flex;flex-direction:column"> | |
| <button onclick="closeVideoPlayer()" style="position:absolute;top:-40px;right:0;background:none;border:none;color:var(--accent);font-size:28px;cursor:pointer;z-index:10001">β</button> | |
| <video id="annotatedVideo" controls style="width:100%;height:100%;background:#000;border:1px solid var(--border)" playsinline></video> | |
| </div> | |
| </div> | |
| <!-- Total unique --> | |
| <!-- <div class="s-sect"> | |
| <div class="s-label">Unique Objects Counted</div> | |
| <div class="total-banner"> | |
| <div class="total-label">TOTAL</div> | |
| <div class="total-val" id="totalUnique">0</div> | |
| </div> | |
| <div class="counter-list" id="counterList"> | |
| <div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:8px">No data yet</div> | |
| </div> | |
| </div> --> | |
| </aside> | |
| <!-- ββ CONTENT ββ --> | |
| <div class="content"> | |
| <div class="view-tabs"> | |
| <button class="view-tab active" onclick="switchTab('video',this)">VIDEO FEED</button> | |
| <button class="view-tab" onclick="switchTab('dashboard',this)">ANALYTICS</button> | |
| <button class="view-tab" onclick="switchTab('logs',this)">LOG FILES</button> | |
| </div> | |
| <!-- VIDEO --> | |
| <div id="view-video"> | |
| <div id="noSignal" class="no-signal"> | |
| <div class="no-signal-icon">π‘</div> | |
| <div class="no-signal-txt">AWAITING VIDEO FEED</div> | |
| <div class="no-signal-sub">Upload a video and click START</div> | |
| </div> | |
| <canvas id="liveCanvas"></canvas> | |
| <video id="webcamVideo" autoplay muted playsinline style="display:none"></video> | |
| <div id="bufferInfo">BUF: 0</div> | |
| </div> | |
| <!-- DASHBOARD --> | |
| <div id="view-dashboard"> | |
| <div class="dash-toolbar"> | |
| <div class="field"> | |
| <label>FILTER BY SCENE</label> | |
| <select id="sceneFilter" onchange="applySceneFilter()"> | |
| <option value="">All scenes</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="dashboard-layout"> | |
| <div class="dashboard-main"> | |
| <div class="dash-grid" id="dashStats"></div> | |
| <div class="chart-card"> | |
| <h3 id="objectsChartTitle">Objects by Class - All Scenes</h3> | |
| <div class="bar-chart" id="barChart"></div> | |
| </div> | |
| <div class="chart-card"> | |
| <h3>Traffic Intensity Timeline (10s buckets)</h3> | |
| <canvas id="timelineCanvas"></canvas> | |
| </div> | |
| <div class="chart-card"> | |
| <h3 id="sceneComparisonTitle">Scene Comparison</h3> | |
| <table class="scene-table"> | |
| <thead> | |
| <tr> | |
| <th>SCENE</th><th>DURATION</th><th>TOTAL</th> | |
| <th>CARS</th><th>PEDESTRIANS</th><th>TRUCKS/BUS</th> | |
| </tr> | |
| </thead> | |
| <tbody id="sceneTable"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="dashboard-side"> | |
| <div class="chart-card"> | |
| <h3 id="heatmapTitle">Position Heatmap of the Objects</h3> | |
| <canvas id="heatmapCanvas"></canvas> | |
| <div class="heatmap-meta"> | |
| <span id="heatmapPoints">0 positions</span> | |
| <span id="heatmapPeak">Peak 0</span> | |
| </div> | |
| <div class="heatmap-legend" id="heatmapLegend"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- LOGS --> | |
| <div id="view-logs"> | |
| <div id="logList"><div style="font-family:var(--mono);font-size:12px;color:var(--dim)">No logs yet.</div></div> | |
| </div> | |
| </div> | |
| <!-- ββ RIGHT INFO SIDEBAR ββ --> | |
| <aside class="right-sidebar"> | |
| <div class="s-sect"> | |
| <div class="s-label">Frame Info</div> | |
| <button class="btn btn-primary" id="startBtn" onclick="startProcessing()">βΆ START ANALYSIS</button> | |
| <button class="btn btn-secondary" id="stopBtn" style="display:none" onclick="stopProcessing()">β STOP</button> | |
| <button class="btn btn-secondary" id="watchVideoBtn" style="display:none;text-align:center;margin-top:7px" onclick="openVideoPlayer()">βΆ WATCH VIDEO</button> | |
| <div class="prog-bar"><div class="prog-fill" id="progFill"></div></div> | |
| <div class="prog-label" id="progLabel"></div> | |
| <a class="btn btn-secondary" id="downloadVideoBtn" style="display:none;text-align:center;text-decoration:none;margin-top:7px" download>β¬ DOWNLOAD ANNOTATED VIDEO</a> | |
| </div> | |
| <div class="s-sect" id="frameInfoPanel" style="display:none"> | |
| <div class="frame-info" id="frameInfo"></div> | |
| </div> | |
| <!-- Total unique --> | |
| <div class="s-sect"> | |
| <div class="s-label">Unique Objects Counted</div> | |
| <div class="total-banner"> | |
| <div class="total-label">TOTAL</div> | |
| <div class="total-val" id="totalUnique">0</div> | |
| </div> | |
| <div class="counter-list" id="counterList"> | |
| <div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:8px">No data yet</div> | |
| </div> | |
| </div> | |
| <!-- Classes --> | |
| <div class="s-sect"> | |
| <div class="s-label">Track Classes</div> | |
| <div class="class-grid" id="classGrid"></div> | |
| </div> | |
| </aside> | |
| </main> | |
| <div id="toast"></div> | |
| <script> | |
| // ββ CONFIG ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const API_BASE = window.location.origin; | |
| const CLASS_COLORS = { | |
| person: '#ff3860', | |
| bicycle: '#00ff88', | |
| car: '#ffb300', | |
| motorbike: '#ff6eb4', | |
| bus: '#2f80ff', | |
| truck: '#ce93d8', | |
| }; | |
| const ALL_CLASSES = ['person','bicycle','car','motorbike','bus','truck']; | |
| // ββ STATE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let currentSid = null; | |
| let sseSource = null; | |
| let selectedFile = null; | |
| let pollTimer = null; | |
| let dashboardCache = null; | |
| let webcamStream = null; | |
| let webcamSid = null; | |
| let webcamTimer = null; | |
| let webcamBusy = false; | |
| // Canvas | |
| const canvas = document.getElementById('liveCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // ββ Playback queue ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Each entry: { src: 'data:image/jpeg;base64,...', stats: {...}, pts: float } | |
| // pts = video time in seconds when this frame should be displayed. | |
| let frameQueue = []; // ordered list of received frames | |
| let playbackStart = null; // performance.now() when playback started | |
| let videoStart = null; // pts of the first frame (usually ~0) | |
| let rafId = null; | |
| let lastRenderedPts = -1; | |
| // ββ INIT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| window.onload = () => { | |
| buildClassGrid(); | |
| setInterval(() => { | |
| document.getElementById('clock').textContent = | |
| new Date().toLocaleTimeString('en-GB', {hour12: false}); | |
| }, 1000); | |
| checkHealth(); | |
| restoreLastSession(); | |
| document.getElementById('videoFile').addEventListener('change', e => { | |
| selectedFile = e.target.files[0]; | |
| document.getElementById('fileName').textContent = selectedFile?.name || ''; | |
| if (selectedFile) document.getElementById('videoUrl').value = ''; | |
| }); | |
| document.getElementById('videoUrl').addEventListener('input', e => { | |
| if (e.target.value.trim()) { | |
| selectedFile = null; | |
| document.getElementById('videoFile').value = ''; | |
| document.getElementById('fileName').textContent = ''; | |
| } | |
| }); | |
| const zone = document.getElementById('uploadZone'); | |
| zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); }); | |
| zone.addEventListener('dragleave', () => zone.classList.remove('drag')); | |
| zone.addEventListener('drop', e => { | |
| e.preventDefault(); zone.classList.remove('drag'); | |
| if (e.dataTransfer.files[0]) { | |
| selectedFile = e.dataTransfer.files[0]; | |
| document.getElementById('fileName').textContent = selectedFile.name; | |
| document.getElementById('videoUrl').value = ''; | |
| } | |
| }); | |
| }; | |
| function buildClassGrid() { | |
| const grid = document.getElementById('classGrid'); | |
| ALL_CLASSES.forEach(cls => { | |
| const lbl = document.createElement('label'); | |
| lbl.className = 'cls-chip on'; | |
| lbl.innerHTML = ` | |
| <input type="checkbox" value="${cls}" checked/> | |
| <span class="cls-dot" style="background:${CLASS_COLORS[cls]}"></span> | |
| <span class="cls-name">${cls}</span>`; | |
| lbl.querySelector('input').addEventListener('change', function() { | |
| lbl.classList.toggle('on', this.checked); | |
| }); | |
| grid.appendChild(lbl); | |
| }); | |
| } | |
| async function checkHealth() { | |
| try { | |
| const r = await fetch(`${API_BASE}/health`); | |
| setConn(r.ok ? 'live' : 'err', r.ok ? 'CONNECTED' : 'SERVER ERROR'); | |
| } catch { setConn('err', 'OFFLINE'); } | |
| } | |
| function setConn(cls, label) { | |
| document.getElementById('connDot').className = 'dot ' + cls; | |
| document.getElementById('connLabel').textContent = label; | |
| } | |
| // ββ TAB SWITCHING ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function switchTab(tab, btn) { | |
| document.querySelectorAll('.view-tab, .nav-btn').forEach(b => b.classList.remove('active')); | |
| if (btn) btn.classList.add('active'); | |
| document.getElementById('view-video').style.display = tab === 'video' ? 'flex' : 'none'; | |
| document.getElementById('view-dashboard').style.display = tab === 'dashboard' ? 'block' : 'none'; | |
| document.getElementById('view-logs').style.display = tab === 'logs' ? 'block' : 'none'; | |
| if (tab === 'dashboard') loadDashboard(); | |
| if (tab === 'logs') loadLogs(); | |
| } | |
| // ββ PROCESSING ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function startProcessing() { | |
| if (webcamSid || webcamStream) await stopWebcam(false); | |
| const videoUrl = document.getElementById('videoUrl').value.trim(); | |
| if (!selectedFile && !videoUrl) { toast('Upload a video file or enter a video URL.', 'err'); return; } | |
| const selected = getSelectedClasses(); | |
| if (!selected.length) { toast('Select at least one class.', 'err'); return; } | |
| const fd = new FormData(); | |
| if (selectedFile) fd.append('file', selectedFile); | |
| else fd.append('video_url', videoUrl); | |
| fd.append('scene_name', document.getElementById('sceneName').value || 'scene_01'); | |
| fd.append('group_id', document.getElementById('groupId').value || 'group_01'); | |
| fd.append('classes', selected.join(',')); | |
| fd.append('conf', document.getElementById('confVal').value); | |
| fd.append('model', document.getElementById('modelSel').value); | |
| setUI('processing'); | |
| resetCounters(); | |
| // Show upload progress bar | |
| const progBar = document.getElementById('uploadProgBar'); | |
| const progFill = document.getElementById('uploadProgFill'); | |
| const progLabel = document.getElementById('uploadProgLabel'); | |
| progBar.style.display = 'block'; | |
| progFill.style.width = '0%'; | |
| progLabel.style.display = 'block'; | |
| progLabel.textContent = selectedFile ? 'Uploading...' : 'Fetching URL...'; | |
| try { | |
| // Use XMLHttpRequest for progress | |
| await new Promise((resolve, reject) => { | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open('POST', `${API_BASE}/upload`); | |
| xhr.onload = async function() { | |
| progFill.style.width = '100%'; | |
| progLabel.textContent = 'Processing...'; | |
| if (xhr.status >= 200 && xhr.status < 300) { | |
| try { | |
| const data = JSON.parse(xhr.responseText); | |
| currentSid = data.session_id; | |
| document.getElementById('sceneName').value = data.scene_name; | |
| localStorage.setItem('trafficTracker:lastSessionId', currentSid); | |
| toast(`Session: ${currentSid}`, 'ok'); | |
| startSSE(currentSid); | |
| startPoll(currentSid); | |
| resolve(); | |
| } catch(e) { | |
| reject(new Error('Invalid server response')); | |
| } | |
| } else { | |
| reject(new Error(xhr.responseText)); | |
| } | |
| }; | |
| xhr.onerror = function() { | |
| reject(new Error('Network error')); | |
| }; | |
| xhr.upload.onprogress = function(e) { | |
| if (selectedFile && e.lengthComputable) { | |
| const pct = Math.round(e.loaded / e.total * 100); | |
| progFill.style.width = pct + '%'; | |
| progLabel.textContent = `Uploading... ${pct}%`; | |
| } | |
| }; | |
| xhr.send(fd); | |
| }); | |
| } catch(e) { | |
| toast('Upload failed: ' + e.message, 'err'); | |
| setUI('idle'); | |
| } finally { | |
| // Hide upload progress bar after a short delay | |
| setTimeout(() => { | |
| progBar.style.display = 'none'; | |
| progLabel.style.display = 'none'; | |
| }, 1200); | |
| } | |
| } | |
| function getSelectedClasses() { | |
| return [...document.querySelectorAll('#classGrid input:checked')].map(i => i.value); | |
| } | |
| function stopProcessing(showToast = true) { | |
| if (webcamSid || webcamStream) { | |
| stopWebcam(showToast); | |
| return; | |
| } | |
| if (sseSource) { sseSource.close(); sseSource = null; } | |
| if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } | |
| if (rafId) { cancelAnimationFrame(rafId); rafId = null; } | |
| frameQueue = []; | |
| playbackStart = null; | |
| videoStart = null; | |
| lastRenderedPts = -1; | |
| setUI('idle'); | |
| if (showToast) toast('Stopped.', ''); | |
| } | |
| async function startWebcam() { | |
| const selected = getSelectedClasses(); | |
| if (!selected.length) { toast('Select at least one class.', 'err'); return; } | |
| stopProcessing(false); | |
| resetCounters(); | |
| setUI('processing'); | |
| setConn('proc', 'WEBCAM'); | |
| try { | |
| webcamStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'environment' }, | |
| audio: false, | |
| }); | |
| const video = document.getElementById('webcamVideo'); | |
| video.srcObject = webcamStream; | |
| await video.play(); | |
| const fd = new FormData(); | |
| fd.append('scene_name', document.getElementById('sceneName').value || 'webcam'); | |
| fd.append('group_id', document.getElementById('groupId').value || 'group_01'); | |
| fd.append('classes', selected.join(',')); | |
| fd.append('conf', document.getElementById('confVal').value); | |
| fd.append('model', document.getElementById('modelSel').value); | |
| const r = await fetch(`${API_BASE}/webcam/start`, { method: 'POST', body: fd }); | |
| if (!r.ok) throw new Error(await r.text()); | |
| const data = await r.json(); | |
| webcamSid = data.session_id; | |
| currentSid = webcamSid; | |
| document.getElementById('sceneName').value = data.scene_name; | |
| document.getElementById('noSignal').style.display = 'none'; | |
| canvas.style.display = 'block'; | |
| document.getElementById('bufferInfo').style.display = 'block'; | |
| document.getElementById('bufferInfo').textContent = 'WEBCAM'; | |
| toast(`Webcam session: ${webcamSid}`, 'ok'); | |
| webcamTimer = setInterval(captureWebcamFrame, 220); | |
| } catch (e) { | |
| toast('Webcam failed: ' + e.message, 'err'); | |
| await stopWebcam(false); | |
| } | |
| } | |
| async function captureWebcamFrame() { | |
| if (!webcamSid || webcamBusy) return; | |
| const video = document.getElementById('webcamVideo'); | |
| if (!video.videoWidth || !video.videoHeight) return; | |
| webcamBusy = true; | |
| try { | |
| const capture = document.createElement('canvas'); | |
| capture.width = video.videoWidth; | |
| capture.height = video.videoHeight; | |
| capture.getContext('2d').drawImage(video, 0, 0, capture.width, capture.height); | |
| const blob = await new Promise(resolve => capture.toBlob(resolve, 'image/jpeg', 0.75)); | |
| if (!blob) return; | |
| const fd = new FormData(); | |
| fd.append('file', blob, 'webcam.jpg'); | |
| const r = await fetch(`${API_BASE}/webcam/frame/${encodeURIComponent(webcamSid)}`, { | |
| method: 'POST', | |
| body: fd, | |
| }); | |
| if (!r.ok) return; | |
| const data = await r.json(); | |
| const img = new Image(); | |
| img.onload = () => { | |
| if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth; | |
| if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight; | |
| ctx.drawImage(img, 0, 0); | |
| }; | |
| img.src = 'data:image/jpeg;base64,' + data.frame; | |
| applyStats(data.stats); | |
| document.getElementById('bufferInfo').textContent = 'WEBCAM LIVE'; | |
| } finally { | |
| webcamBusy = false; | |
| } | |
| } | |
| async function stopWebcam(showToast = true) { | |
| if (webcamTimer) { | |
| clearInterval(webcamTimer); | |
| webcamTimer = null; | |
| } | |
| if (webcamStream) { | |
| webcamStream.getTracks().forEach(track => track.stop()); | |
| webcamStream = null; | |
| } | |
| const sid = webcamSid; | |
| webcamSid = null; | |
| webcamBusy = false; | |
| if (sid) { | |
| try { | |
| await fetch(`${API_BASE}/webcam/stop/${encodeURIComponent(sid)}`, { method: 'POST' }); | |
| loadDashboard(); | |
| } catch {} | |
| } | |
| setUI('idle'); | |
| if (showToast) toast('Webcam stopped.', ''); | |
| } | |
| function setUI(state) { | |
| const start = document.getElementById('startBtn'); | |
| const stop = document.getElementById('stopBtn'); | |
| const webcam = document.getElementById('webcamBtn'); | |
| if (state === 'processing') { | |
| start.disabled = true; | |
| webcam.disabled = true; | |
| stop.style.display = 'block'; | |
| setConn('proc', 'PROCESSING'); | |
| } else { | |
| start.disabled = false; | |
| webcam.disabled = false; | |
| stop.style.display = 'none'; | |
| document.getElementById('progFill').style.width = '0%'; | |
| document.getElementById('progLabel').textContent = ''; | |
| setConn('live', 'CONNECTED'); | |
| document.getElementById('bufferInfo').style.display = 'none'; | |
| } | |
| } | |
| function resetCounters() { | |
| document.getElementById('totalUnique').textContent = '0'; | |
| document.getElementById('counterList').innerHTML = | |
| '<div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:8px">Processingβ¦</div>'; | |
| document.getElementById('frameInfoPanel').style.display = 'none'; | |
| } | |
| // ββ SSE STREAM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function startSSE(sid) { | |
| if (sseSource) sseSource.close(); | |
| // Show canvas, hide no-signal | |
| document.getElementById('noSignal').style.display = 'none'; | |
| canvas.style.display = 'block'; | |
| document.getElementById('bufferInfo').style.display = 'block'; | |
| // Reset playback state | |
| frameQueue = []; | |
| playbackStart = null; | |
| videoStart = null; | |
| lastRenderedPts = -1; | |
| // Start render loop | |
| if (rafId) cancelAnimationFrame(rafId); | |
| renderLoop(); | |
| sseSource = new EventSource(`${API_BASE}/stream/${sid}`); | |
| sseSource.onmessage = ev => { | |
| let msg; | |
| try { msg = JSON.parse(ev.data); } catch { return; } | |
| // ββ DONE event βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (msg.event === 'done') { | |
| sseSource.close(); | |
| sseSource = null; | |
| if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } | |
| document.getElementById('progFill').style.width = '100%'; | |
| const summary = msg.summary; | |
| if (summary) { | |
| const total = summary.total_unique !== undefined | |
| ? summary.total_unique | |
| : Object.values(summary.count_per_class || {}).reduce((a, b) => a + b, 0); | |
| updateCounters(summary.count_per_class || {}, total); | |
| toast(`Done! ${total} unique objects counted.`, 'ok'); | |
| } else { | |
| toast('Processing complete!', 'ok'); | |
| } | |
| setConn('live', 'DONE'); | |
| document.getElementById('startBtn').disabled = false; | |
| document.getElementById('stopBtn').style.display = 'none'; | |
| // Show annotated video download and watch buttons | |
| showSessionVideoActions(currentSid); | |
| return; | |
| } | |
| // ββ FRAME event ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (msg.frame) { | |
| // Preload image so it's ready to paint without decode latency | |
| const img = new Image(); | |
| img.src = 'data:image/jpeg;base64,' + msg.frame; | |
| frameQueue.push({ | |
| img, | |
| stats: msg.stats, | |
| pts: msg.pts ?? (msg.stats ? msg.stats.timestamp : 0), | |
| }); | |
| } | |
| }; | |
| sseSource.onerror = () => { | |
| if (currentSid) checkStatus(currentSid); | |
| }; | |
| } | |
| // ββ Sequential frame renderer βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Plays frames one-by-one at native video FPS. Never skips a frame. | |
| // If YOLO is slower than video FPS, we simply wait for the next frame to | |
| // arrive; playback auto-resumes the instant one is ready. | |
| // frameInterval is derived from pts deltas so it matches the source video exactly. | |
| let frameInterval = 1000 / 30; // ms between frames (recalculated on first frames) | |
| let nextFrameAt = 0; // performance.now() target for the next frame | |
| function renderLoop(now) { | |
| const bufEl = document.getElementById('bufferInfo'); | |
| rafId = requestAnimationFrame(renderLoop); | |
| if (frameQueue.length === 0) { | |
| bufEl.textContent = 'BUF: 0 β waiting'; | |
| return; | |
| } | |
| // Derive frame interval from pts of first two frames (once) | |
| if (frameQueue.length >= 2 && frameInterval === 1000 / 30) { | |
| const delta = frameQueue[1].pts - frameQueue[0].pts; | |
| if (delta > 0) frameInterval = delta * 1000; | |
| } | |
| // Initialise schedule on very first frame | |
| if (nextFrameAt === 0) nextFrameAt = now; | |
| // Not yet time β keep waiting | |
| if (now < nextFrameAt) { | |
| bufEl.textContent = `BUF: ${frameQueue.length}`; | |
| return; | |
| } | |
| // Pop and render next frame in order | |
| const item = frameQueue.shift(); | |
| if (item.img.complete && item.img.naturalWidth > 0) { | |
| if (canvas.width !== item.img.naturalWidth) canvas.width = item.img.naturalWidth; | |
| if (canvas.height !== item.img.naturalHeight) canvas.height = item.img.naturalHeight; | |
| ctx.drawImage(item.img, 0, 0); | |
| } | |
| if (item.stats) applyStats(item.stats); | |
| // Schedule next frame; if we fell behind (e.g. tab was hidden), | |
| // snap to now so we don't burst-dump a backlog. | |
| nextFrameAt = Math.max(nextFrameAt + frameInterval, now - frameInterval); | |
| bufEl.textContent = `BUF: ${frameQueue.length}`; | |
| } | |
| // ββ STATS UPDATE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function applyStats(stats) { | |
| if (!stats) return; | |
| const total = typeof stats.total_unique === 'number' | |
| ? stats.total_unique | |
| : Object.values(stats.cumulative || {}).reduce((a, b) => a + b, 0); | |
| updateCounters(stats.cumulative || {}, total); | |
| document.getElementById('frameInfoPanel').style.display = 'block'; | |
| document.getElementById('frameInfo').innerHTML = | |
| `FRAME <span class="fi-val">${stats.frame ?? '--'}</span><br>` + | |
| `TIME <span class="fi-val">${stats.timestamp ?? '--'}s</span><br>` + | |
| `SCENE <span class="fi-val">${stats.scene ?? '--'}</span><br>` + | |
| `DETECTED <span class="fi-val">${stats.detections ?? 0}</span><br>` + | |
| `VISIBLE <span class="fi-val" style="color:${stats.any_visible ? 'var(--green)' : 'var(--dim)'}">` + | |
| `${stats.any_visible ? 'YES' : 'NO'}</span>`; | |
| } | |
| function updateCounters(cumulative, total) { | |
| document.getElementById('totalUnique').textContent = Number.isFinite(total) ? total : 0; | |
| const list = document.getElementById('counterList'); | |
| const entries = Object.entries(cumulative).filter(([, v]) => v > 0); | |
| if (!entries.length) { | |
| list.innerHTML = '<div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:8px">No crossings yet</div>'; | |
| return; | |
| } | |
| list.innerHTML = entries.map(([cls, cnt]) => ` | |
| <div class="cnt-card"> | |
| <div class="cnt-left"> | |
| <span class="cls-dot" style="background:${CLASS_COLORS[cls] || '#888'}"></span> | |
| ${cls} | |
| </div> | |
| <div class="cnt-val">${cnt}</div> | |
| </div> | |
| `).join(''); | |
| } | |
| // ββ POLL ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function startPoll(sid) { | |
| if (pollTimer) clearInterval(pollTimer); | |
| pollTimer = setInterval(() => checkStatus(sid), 1500); | |
| } | |
| async function checkStatus(sid) { | |
| try { | |
| const r = await fetch(`${API_BASE}/status/${sid}`); | |
| const d = await r.json(); | |
| if (d.total_frames > 0) { | |
| const pct = Math.min(100, Math.round(d.progress / d.total_frames * 100)); | |
| document.getElementById('progFill').style.width = pct + '%'; | |
| document.getElementById('progLabel').textContent = `${d.progress} / ${d.total_frames} (${pct}%)`; | |
| } | |
| if (d.status === 'done' || d.status === 'error') { | |
| clearInterval(pollTimer); | |
| pollTimer = null; | |
| if (d.status === 'error') toast('Error: ' + d.error, 'err'); | |
| } | |
| } catch {} | |
| } | |
| // ββ DASHBOARD ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadDashboard() { | |
| try { | |
| const r = await fetch(`${API_BASE}/dashboard`); | |
| const d = await r.json(); | |
| dashboardCache = d; | |
| populateSceneFilter(d.scenes || []); | |
| applySceneFilter(); | |
| } catch { | |
| document.getElementById('dashStats').innerHTML = | |
| '<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">Server not reachable.</div>'; | |
| } | |
| } | |
| function populateSceneFilter(scenes) { | |
| const select = document.getElementById('sceneFilter'); | |
| const current = select.value; | |
| const names = [...new Set(scenes.map(sc => sc.scene).filter(Boolean))].sort(); | |
| select.innerHTML = '<option value="">All scenes</option>' + | |
| names.map(name => `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`).join(''); | |
| if (names.includes(current)) select.value = current; | |
| } | |
| function applySceneFilter() { | |
| if (!dashboardCache) return; | |
| const scene = document.getElementById('sceneFilter').value; | |
| const scenes = (dashboardCache.scenes || []).filter(sc => !scene || sc.scene === scene); | |
| const global_counts = {}; | |
| for (const sc of scenes) { | |
| for (const [cls, cnt] of Object.entries(sc.count_per_class || {})) { | |
| global_counts[cls] = (global_counts[cls] || 0) + cnt; | |
| } | |
| } | |
| renderDashboard({ | |
| scenes, | |
| global_counts, | |
| total_scenes: scenes.length, | |
| total_objects: Object.values(global_counts).reduce((a, b) => a + b, 0), | |
| }); | |
| } | |
| function escapeHtml(value) { | |
| return String(value).replace(/[&<>"']/g, ch => ({ | |
| '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' | |
| }[ch])); | |
| } | |
| function renderDashboard(d) { | |
| const { global_counts = {}, total_scenes = 0, total_objects = 0, scenes = [] } = d; | |
| const totalDur = scenes.reduce((s, sc) => s + (sc.duration_sec || 0), 0); | |
| const selectedScene = document.getElementById('sceneFilter')?.value || ''; | |
| const scope = selectedScene ? selectedScene : 'All Scenes'; | |
| document.getElementById('objectsChartTitle').textContent = `Objects by Class - ${scope}`; | |
| document.getElementById('sceneComparisonTitle').textContent = selectedScene ? 'Selected Scene' : 'Scene Comparison'; | |
| document.getElementById('dashStats').innerHTML = [ | |
| statCard('SCENES', total_scenes, 'videos'), | |
| statCard('TOTAL OBJECTS', total_objects, 'unique crossings'), | |
| statCard('TOTAL DURATION',Math.round(totalDur), 'seconds'), | |
| statCard('AVG / SCENE', total_scenes ? Math.round(total_objects / total_scenes) : 0, 'objects'), | |
| ].join(''); | |
| const max = Math.max(...Object.values(global_counts), 1); | |
| document.getElementById('barChart').innerHTML = | |
| Object.entries(global_counts).map(([cls, cnt]) => ` | |
| <div class="bar-row"> | |
| <div class="bar-label">${cls}</div> | |
| <div class="bar-track"> | |
| <div class="bar-fill" style="width:${(cnt/max*100).toFixed(1)}%;background:${CLASS_COLORS[cls]||'#888'}"></div> | |
| </div> | |
| <div class="bar-count">${cnt}</div> | |
| </div>`).join('') || | |
| '<div style="color:var(--dim);font-family:var(--mono);font-size:12px">No data yet</div>'; | |
| renderTimeline(scenes); | |
| renderPositionHeatmap(scenes); | |
| document.getElementById('sceneTable').innerHTML = | |
| scenes.map(sc => { | |
| const cp = sc.count_per_class || {}; | |
| return `<tr> | |
| <td><span class="badge">${sc.scene}</span></td> | |
| <td>${sc.duration_sec}s</td> | |
| <td style="color:var(--accent)">${sc.total_unique_objects ?? sc.total_unique ?? 0}</td> | |
| <td>${cp.car || 0}</td> | |
| <td>${cp.person || 0}</td> | |
| <td>${(cp.truck || 0) + (cp.bus || 0)}</td> | |
| </tr>`; | |
| }).join('') || | |
| '<tr><td colspan="6" style="color:var(--dim);font-family:var(--mono);padding:20px">No scenes yet.</td></tr>'; | |
| } | |
| function statCard(label, value, unit) { | |
| return `<div class="stat-card"> | |
| <div class="sc-label">${label}</div> | |
| <div class="sc-value">${value}</div> | |
| <div class="sc-unit">${unit}</div> | |
| </div>`; | |
| } | |
| function renderTimeline(scenes) { | |
| const c = document.getElementById('timelineCanvas'); | |
| const ctx2 = c.getContext('2d'); | |
| c.width = c.offsetWidth || 800; | |
| c.height = 110; | |
| const merged = {}; | |
| for (const sc of scenes) | |
| for (const t of (sc.temporal_distribution || [])) | |
| merged[t.bucket_10s] = (merged[t.bucket_10s] || 0) + t.detections; | |
| const keys = Object.keys(merged).map(Number).sort((a,b) => a - b); | |
| ctx2.fillStyle = '#0d1218'; ctx2.fillRect(0, 0, c.width, c.height); | |
| if (!keys.length) return; | |
| const vals = keys.map(k => merged[k]); | |
| const maxV = Math.max(...vals, 1); | |
| const pad = 12; | |
| const w = (c.width - pad * 2) / Math.max(keys.length - 1, 1); | |
| const pts = keys.map((k, i) => [pad + i * w, c.height - pad - (merged[k] / maxV) * (c.height - pad * 2)]); | |
| ctx2.strokeStyle = '#1e2d3d'; ctx2.lineWidth = 1; | |
| [.25,.5,.75,1].forEach(f => { | |
| const y = c.height - pad - f * (c.height - pad * 2); | |
| ctx2.beginPath(); ctx2.moveTo(0, y); ctx2.lineTo(c.width, y); ctx2.stroke(); | |
| }); | |
| ctx2.beginPath(); | |
| ctx2.moveTo(pts[0][0], c.height - pad); | |
| pts.forEach(([x, y]) => ctx2.lineTo(x, y)); | |
| ctx2.lineTo(pts[pts.length-1][0], c.height - pad); | |
| ctx2.closePath(); | |
| const g = ctx2.createLinearGradient(0, 0, 0, c.height); | |
| g.addColorStop(0, 'rgba(0,229,255,.38)'); | |
| g.addColorStop(1, 'rgba(0,229,255,0)'); | |
| ctx2.fillStyle = g; ctx2.fill(); | |
| ctx2.beginPath(); ctx2.strokeStyle = '#00e5ff'; ctx2.lineWidth = 2; | |
| pts.forEach(([x, y], i) => i === 0 ? ctx2.moveTo(x, y) : ctx2.lineTo(x, y)); | |
| ctx2.stroke(); | |
| ctx2.fillStyle = '#4a6070'; ctx2.font = '10px Share Tech Mono'; ctx2.textAlign = 'center'; | |
| keys.forEach((k, i) => { | |
| if (i % Math.ceil(keys.length / 8) === 0) | |
| ctx2.fillText(`${k * 10}s`, pad + i * w, c.height - 1); | |
| }); | |
| } | |
| function renderPositionHeatmap(scenes) { | |
| const c = document.getElementById('heatmapCanvas'); | |
| const ctx = c.getContext('2d'); | |
| const cssWidth = c.offsetWidth || 360; | |
| const cssHeight = c.offsetHeight || Math.round(cssWidth * 1.25); | |
| const dpr = window.devicePixelRatio || 1; | |
| c.width = Math.round(cssWidth * dpr); | |
| c.height = Math.round(cssHeight * dpr); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| ctx.fillStyle = '#0d1218'; | |
| ctx.fillRect(0, 0, cssWidth, cssHeight); | |
| const merged = {}; | |
| const classTotals = {}; | |
| let cols = 24; | |
| let rows = 24; | |
| let totalPoints = 0; | |
| for (const sc of scenes) { | |
| const hm = sc.position_heatmap || {}; | |
| cols = hm.cols || cols; | |
| rows = hm.rows || rows; | |
| totalPoints += hm.total_points || 0; | |
| for (const cell of (hm.cells || [])) { | |
| const key = `${cell.x},${cell.y}`; | |
| if (!merged[key]) merged[key] = { count: 0, classes: {} }; | |
| merged[key].count += cell.count || 0; | |
| for (const [cls, count] of Object.entries(cell.classes || {})) { | |
| merged[key].classes[cls] = (merged[key].classes[cls] || 0) + count; | |
| classTotals[cls] = (classTotals[cls] || 0) + count; | |
| } | |
| } | |
| } | |
| const values = Object.values(merged).map(cell => cell.count); | |
| const maxDensity = Math.max(...values, 0); | |
| const leftPad = 42; | |
| const rightPad = 14; | |
| const topPad = 18; | |
| const bottomPad = 34; | |
| const plotW = cssWidth - leftPad - rightPad; | |
| const plotH = cssHeight - topPad - bottomPad; | |
| const cellW = plotW / cols; | |
| const cellH = plotH / rows; | |
| ctx.fillStyle = '#4a6070'; | |
| ctx.font = '10px Share Tech Mono'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'top'; | |
| for (let i = 0; i <= 4; i++) { | |
| const x = leftPad + (plotW / 4) * i; | |
| ctx.fillText(`${i * 25}%`, x, cssHeight - bottomPad + 12); | |
| } | |
| ctx.textAlign = 'right'; | |
| ctx.textBaseline = 'middle'; | |
| for (let i = 0; i <= 4; i++) { | |
| const y = topPad + (plotH / 4) * i; | |
| ctx.fillText(`${i * 25}%`, leftPad - 9, y); | |
| } | |
| ctx.strokeStyle = '#1e2d3d'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 4; i++) { | |
| const x = leftPad + (plotW / 4) * i; | |
| const y = topPad + (plotH / 4) * i; | |
| ctx.beginPath(); ctx.moveTo(x, topPad); ctx.lineTo(x, topPad + plotH); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(leftPad, y); ctx.lineTo(leftPad + plotW, y); ctx.stroke(); | |
| } | |
| if (!maxDensity) { | |
| ctx.fillStyle = '#4a6070'; | |
| ctx.font = '12px Share Tech Mono'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText('No position data yet', leftPad + plotW / 2, topPad + plotH / 2); | |
| } else { | |
| for (const [key, cell] of Object.entries(merged)) { | |
| const [x, y] = key.split(',').map(Number); | |
| const intensity = Math.sqrt(cell.count / maxDensity); | |
| const dominantClass = Object.entries(cell.classes) | |
| .sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown'; | |
| ctx.fillStyle = hexToRgba(CLASS_COLORS[dominantClass] || '#c8d8e8', 0.18 + intensity * 0.78); | |
| ctx.fillRect(leftPad + x * cellW, topPad + y * cellH, Math.ceil(cellW), Math.ceil(cellH)); | |
| } | |
| } | |
| ctx.strokeStyle = '#00e5ff'; | |
| ctx.lineWidth = 1.5; | |
| ctx.strokeRect(leftPad, topPad, plotW, plotH); | |
| document.getElementById('heatmapPoints').textContent = `${totalPoints.toLocaleString()} positions`; | |
| document.getElementById('heatmapPeak').textContent = `Peak ${maxDensity.toLocaleString()}`; | |
| document.getElementById('heatmapLegend').innerHTML = ALL_CLASSES | |
| .filter(cls => classTotals[cls] || scenes.some(sc => (sc.selected_classes || []).includes(cls))) | |
| .map(cls => ` | |
| <div class="heatmap-legend-item"> | |
| <span class="cls-dot" style="background:${CLASS_COLORS[cls] || '#888'}"></span> | |
| <span>${cls}</span> | |
| </div>`).join(''); | |
| } | |
| function hexToRgba(hex, alpha) { | |
| const clean = String(hex).replace('#', ''); | |
| const value = parseInt(clean.length === 3 | |
| ? clean.split('').map(ch => ch + ch).join('') | |
| : clean, 16); | |
| const r = (value >> 16) & 255; | |
| const g = (value >> 8) & 255; | |
| const b = value & 255; | |
| return `rgba(${r},${g},${b},${alpha})`; | |
| } | |
| // ββ LOGS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadLogs() { | |
| try { | |
| const r = await fetch(`${API_BASE}/logs`); | |
| const d = await r.json(); | |
| const el = document.getElementById('logList'); | |
| el.innerHTML = d.logs.length | |
| ? d.logs.map(f => ` | |
| <div class="log-entry"> | |
| <div> | |
| <div class="log-name">${f.name}</div> | |
| <div class="log-meta">${f.size_kb} KB Β· ${new Date(f.modified * 1000).toLocaleString()}</div> | |
| </div> | |
| <div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end"> | |
| ${f.name.toLowerCase().endsWith('.mp4') | |
| ? `<button class="log-dl" onclick="openVideoFile('${encodeURIComponent(f.name)}')">βΆ Watch</button>` | |
| : ''} | |
| <a class="log-dl" href="${API_BASE}/log/${encodeURIComponent(f.name)}" download>β¬ Download</a> | |
| </div> | |
| </div>`).join('') | |
| : '<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">No log files yet.</div>'; | |
| } catch { | |
| document.getElementById('logList').innerHTML = | |
| '<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">Server not reachable.</div>'; | |
| } | |
| } | |
| // ββ TOAST βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function toast(msg, type = '') { | |
| const el = document.getElementById('toast'); | |
| el.textContent = msg; | |
| el.className = 'show ' + type; | |
| clearTimeout(el._t); | |
| el._t = setTimeout(() => el.className = '', 3800); | |
| } | |
| // ββ VIDEO PLAYER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showSessionVideoActions(sid) { | |
| if (!sid) return; | |
| currentSid = sid; | |
| const dlBtn = document.getElementById('downloadVideoBtn'); | |
| const watchBtn = document.getElementById('watchVideoBtn'); | |
| dlBtn.href = `${API_BASE}/download/video/${encodeURIComponent(sid)}`; | |
| dlBtn.style.display = 'block'; | |
| watchBtn.style.display = 'block'; | |
| } | |
| async function restoreLastSession() { | |
| const sid = localStorage.getItem('trafficTracker:lastSessionId'); | |
| if (!sid) return; | |
| try { | |
| const r = await fetch(`${API_BASE}/summary/${encodeURIComponent(sid)}`); | |
| if (!r.ok) return; | |
| const summary = await r.json(); | |
| const total = summary.total_unique !== undefined | |
| ? summary.total_unique | |
| : Object.values(summary.count_per_class || {}).reduce((a, b) => a + b, 0); | |
| updateCounters(summary.count_per_class || {}, total); | |
| showSessionVideoActions(sid); | |
| setConn('live', 'DONE'); | |
| } catch {} | |
| } | |
| function playVideoSource(src) { | |
| const modal = document.getElementById('videoModal'); | |
| const video = document.getElementById('annotatedVideo'); | |
| video.src = src; | |
| modal.style.display = 'flex'; | |
| video.load(); | |
| video.play().catch(() => { | |
| toast('Video ready. Press play in the player.', ''); | |
| }); | |
| } | |
| function openVideoPlayer() { | |
| if (!currentSid) { toast('No session found.', 'err'); return; } | |
| playVideoSource(`${API_BASE}/stream/video/${encodeURIComponent(currentSid)}?t=${Date.now()}`); | |
| } | |
| function openVideoFile(filename) { | |
| playVideoSource(`${API_BASE}/stream/video-file/${filename}?t=${Date.now()}`); | |
| } | |
| function closeVideoPlayer() { | |
| const modal = document.getElementById('videoModal'); | |
| const video = document.getElementById('annotatedVideo'); | |
| modal.style.display = 'none'; | |
| video.pause(); | |
| video.src = ''; | |
| } | |
| </script> | |
| </body> | |
| </html> | |