|
|
@@ -115,14 +115,14 @@
|
|
|
}
|
|
|
|
|
|
<!-- ─────────────────────────────────────────────────────────────────────────── -->
|
|
|
-<!-- SOCKET ENGINE — webcam live preview + manual Snap & Analyze -->
|
|
|
+<!-- SOCKET ENGINE — Three-Column Command Center -->
|
|
|
<!-- ─────────────────────────────────────────────────────────────────────────── -->
|
|
|
@if (isSocketEngine()) {
|
|
|
<div class="container main-content">
|
|
|
- <div class="row">
|
|
|
+ <div class="dashboard-layout">
|
|
|
|
|
|
- <!-- Controls column -->
|
|
|
- <div class="col-left">
|
|
|
+ <!-- ── COL 1: Configuration Sidebar ──────────────────────────────────── -->
|
|
|
+ <div class="dash-sidebar">
|
|
|
<div class="glass-panel backend-controls">
|
|
|
<h3 class="panel-title">NestJS Socket Engine</h3>
|
|
|
|
|
|
@@ -160,13 +160,13 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
<button class="btn btn-danger btn-sm abort-btn" (click)="abortBatch()">
|
|
|
- Abort Batch
|
|
|
+ Abort
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
}
|
|
|
|
|
|
- <!-- Batch Complete HUD — hardware report shown after finalizeBatch() -->
|
|
|
+ <!-- Batch Complete HUD -->
|
|
|
@if (completedReport()) {
|
|
|
<div class="batch-complete-hud glass-panel">
|
|
|
<div class="batch-complete-title">Batch Complete</div>
|
|
|
@@ -176,7 +176,7 @@
|
|
|
<span class="bc-value">{{ completedReport()!.meta.total_images }}</span>
|
|
|
</div>
|
|
|
<div class="bc-stat">
|
|
|
- <span class="bc-label">Successful</span>
|
|
|
+ <span class="bc-label">OK</span>
|
|
|
<span class="bc-value ok">{{ completedReport()!.meta.successful }}</span>
|
|
|
</div>
|
|
|
<div class="bc-stat">
|
|
|
@@ -190,15 +190,14 @@
|
|
|
<span class="bc-value">{{ completedReport()!.meta.avg_inference_ms }} ms</span>
|
|
|
</div>
|
|
|
<div class="bc-stat">
|
|
|
- <span class="bc-label">Avg Round-Trip</span>
|
|
|
+ <span class="bc-label">Avg RTT</span>
|
|
|
<span class="bc-value">{{ completedReport()!.meta.avg_round_trip_ms }} ms</span>
|
|
|
</div>
|
|
|
<div class="bc-stat">
|
|
|
- <span class="bc-label">Total Time</span>
|
|
|
+ <span class="bc-label">Total</span>
|
|
|
<span class="bc-value">{{ completedReport()!.meta.total_time_ms }} ms</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <!-- Ripeness Distribution -->
|
|
|
@if (getBatchDistribution().length > 0) {
|
|
|
<div class="bc-distribution">
|
|
|
<div class="bc-dist-label">Ripeness Distribution</div>
|
|
|
@@ -221,24 +220,15 @@
|
|
|
|
|
|
<!-- Input source toggle -->
|
|
|
<div class="input-mode-toggle">
|
|
|
- <button
|
|
|
- class="input-mode-btn"
|
|
|
- [class.active]="socketInputMode() === 'webcam'"
|
|
|
- (click)="onSocketInputModeChange('webcam')">
|
|
|
- 📷 Webcam
|
|
|
- </button>
|
|
|
- <button
|
|
|
- class="input-mode-btn"
|
|
|
- [class.active]="socketInputMode() === 'gallery'"
|
|
|
- (click)="onSocketInputModeChange('gallery')">
|
|
|
- 🖼 Gallery
|
|
|
- </button>
|
|
|
+ <button class="input-mode-btn" [class.active]="socketInputMode() === 'webcam'"
|
|
|
+ (click)="onSocketInputModeChange('webcam')">📷 Webcam</button>
|
|
|
+ <button class="input-mode-btn" [class.active]="socketInputMode() === 'gallery'"
|
|
|
+ (click)="onSocketInputModeChange('gallery')">🖼 Gallery</button>
|
|
|
</div>
|
|
|
|
|
|
<div class="lego-note">
|
|
|
- <strong>Lego 11:</strong> One frame per analyze. Transmitted
|
|
|
- as raw uncompressed Base64 via <code>vision:analyze</code>.
|
|
|
- No continuous streaming. No binary encoding.
|
|
|
+ <strong>Lego 11:</strong> One frame per analyze. Transmitted as raw Base64
|
|
|
+ via <code>vision:analyze</code>. Every snap is a Batch of 1.
|
|
|
</div>
|
|
|
|
|
|
<!-- Webcam controls -->
|
|
|
@@ -248,15 +238,12 @@
|
|
|
Start Webcam Preview
|
|
|
</button>
|
|
|
} @else {
|
|
|
- <button
|
|
|
- class="btn btn-primary snap-btn"
|
|
|
- (click)="snapAndAnalyze()"
|
|
|
- [disabled]="visionSocket.analyzing() || !visionSocket.connected()">
|
|
|
- {{ visionSocket.analyzing() ? 'Analyzing...' : '📸 Snap & Analyze' }}
|
|
|
- </button>
|
|
|
- <button class="btn btn-outline" (click)="stopWebcam()">
|
|
|
- Stop Webcam
|
|
|
+ <button class="btn btn-primary snap-btn"
|
|
|
+ (click)="snapAndAnalyze()"
|
|
|
+ [disabled]="isBatchActive() || !visionSocket.connected()">
|
|
|
+ {{ isBatchActive() ? 'Processing...' : '📸 Snap & Analyze' }}
|
|
|
</button>
|
|
|
+ <button class="btn btn-outline" (click)="stopWebcam()">Stop Webcam</button>
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -273,193 +260,184 @@
|
|
|
(change)="onGalleryFileSelected($event)" style="display:none">
|
|
|
<div class="upload-icon">📁</div>
|
|
|
@if (!socketGalleryFile && batchQueue().length === 0) {
|
|
|
- <p>Drop images here or click to start Batch Audit (API-Only)</p>
|
|
|
- <p class="upload-hint">Select multiple images to run a full batch session</p>
|
|
|
+ <p>Drop images here or click to select</p>
|
|
|
+ <p class="upload-hint">Multiple images = Batch Audit</p>
|
|
|
} @else if (batchQueue().length > 0) {
|
|
|
- <p>{{ batchQueue().length }} image(s) queued for batch</p>
|
|
|
+ <p>{{ batchQueue().length }} image(s) queued</p>
|
|
|
} @else {
|
|
|
<p>{{ socketGalleryFile!.name }}</p>
|
|
|
}
|
|
|
</div>
|
|
|
- <button
|
|
|
- class="btn btn-primary snap-btn"
|
|
|
- (click)="analyzeGalleryImage()"
|
|
|
- [disabled]="(!socketGalleryFile && batchQueue().length === 0) || visionSocket.analyzing() || !visionSocket.connected()">
|
|
|
- {{ visionSocket.analyzing() ? 'Analyzing...' : (batchQueue().length > 1 ? '🚀 Start Batch Audit' : '🔍 Analyze Image') }}
|
|
|
+ <button class="btn btn-primary snap-btn"
|
|
|
+ (click)="analyzeGalleryImage()"
|
|
|
+ [disabled]="(!socketGalleryFile && batchQueue().length === 0) || isBatchActive() || !visionSocket.connected()">
|
|
|
+ {{ isBatchActive() ? 'Processing...' : (batchQueue().length > 1 ? '🚀 Start Batch Audit' : '🔍 Analyze Image') }}
|
|
|
</button>
|
|
|
}
|
|
|
|
|
|
@if (visionSocket.lastError()) {
|
|
|
<div class="error-note">{{ visionSocket.lastError() }}</div>
|
|
|
}
|
|
|
-
|
|
|
- @if (visionSocket.lastResult()) {
|
|
|
- <div class="result-summary">
|
|
|
- <div class="summary-header">Last Snap Result</div>
|
|
|
- <div class="summary-stat">
|
|
|
- Bunches detected: {{ visionSocket.lastResult()!.total_count }}
|
|
|
- </div>
|
|
|
- <div class="summary-stat">
|
|
|
- Inference: {{ visionSocket.lastResult()!.inference_ms.toFixed(1) }} ms
|
|
|
- </div>
|
|
|
- <div class="summary-stat">
|
|
|
- Processing: {{ visionSocket.lastResult()!.processing_ms.toFixed(1) }} ms
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- }
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- Camera + result column -->
|
|
|
- <div class="col-right">
|
|
|
+ <!-- ── COL 2: Active View ─────────────────────────────────────────────── -->
|
|
|
+ <div class="dash-main">
|
|
|
<div class="glass-panel webcam-panel">
|
|
|
|
|
|
- <!-- ── Webcam mode view ── -->
|
|
|
+ <!-- Webcam mode -->
|
|
|
@if (socketInputMode() === 'webcam') {
|
|
|
@if (webcamActive) {
|
|
|
<h2 class="panel-title">
|
|
|
- {{ snappedFrame ? 'Snap Result' : 'Live Preview' }}
|
|
|
- <span class="engine-label">
|
|
|
- {{ snappedFrame ? 'NestJS ONNX · Lego 11' : 'No inference · Lego 02' }}
|
|
|
- </span>
|
|
|
+ Live Preview
|
|
|
+ <span class="engine-label">NestJS ONNX · Batch-1 · Lego 11</span>
|
|
|
</h2>
|
|
|
-
|
|
|
- <!-- Video always in DOM when webcam active; hidden (not removed) by snap -->
|
|
|
- <div class="webcam-wrapper" [class.active]="!snappedFrame">
|
|
|
+ <div class="webcam-wrapper active">
|
|
|
<video #videoEl autoplay playsinline muted class="webcam-video"></video>
|
|
|
</div>
|
|
|
-
|
|
|
- @if (snappedFrame) {
|
|
|
- <div class="snap-result-wrapper">
|
|
|
- <canvas #snapCanvas class="snap-canvas"></canvas>
|
|
|
- @if (visionSocket.analyzing()) {
|
|
|
- <div class="snap-overlay-spinner">
|
|
|
- <div class="spinner"></div>
|
|
|
- <p>Waiting for NestJS...</p>
|
|
|
- </div>
|
|
|
- }
|
|
|
- </div>
|
|
|
- <button class="btn btn-outline snap-retry" (click)="snappedFrame = null">
|
|
|
- ← Back to Live Preview
|
|
|
- </button>
|
|
|
- }
|
|
|
}
|
|
|
-
|
|
|
@if (!webcamActive) {
|
|
|
<div class="no-results">
|
|
|
Click "Start Webcam Preview" to open the camera.<br>
|
|
|
- Then click "Snap & Analyze" to send one frame to NestJS.
|
|
|
+ Each "Snap & Analyze" submits a Batch of 1 to NestJS.
|
|
|
</div>
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- <!-- ── Gallery mode view ── -->
|
|
|
+ <!-- Gallery mode — show last processed image result -->
|
|
|
@if (socketInputMode() === 'gallery') {
|
|
|
- @if (!snappedFrame) {
|
|
|
+ @if (completedReport() && completedReport()!.results.length > 0) {
|
|
|
+ @if (selectedAuditEntry()?.localBlobUrl) {
|
|
|
+ <h2 class="panel-title">
|
|
|
+ Evidence View
|
|
|
+ <span class="engine-label">{{ selectedAuditEntry()!.image_id }}</span>
|
|
|
+ </h2>
|
|
|
+ <div class="evidence-canvas-wrapper">
|
|
|
+ <canvas #evidenceCanvas class="evidence-canvas"></canvas>
|
|
|
+ </div>
|
|
|
+ } @else {
|
|
|
+ <div class="no-results">Select a row in the manifest to view evidence.</div>
|
|
|
+ }
|
|
|
+ } @else if (isBatchActive()) {
|
|
|
<div class="no-results">
|
|
|
- Choose an image from your gallery and click "Analyze Image".
|
|
|
+ <div class="spinner"></div>
|
|
|
+ <p>Processing image {{ currentBatchIndex() + 1 }} of {{ totalBatchCount }}…</p>
|
|
|
</div>
|
|
|
- }
|
|
|
-
|
|
|
- @if (snappedFrame) {
|
|
|
- <h2 class="panel-title">
|
|
|
- {{ visionSocket.analyzing() ? 'Analyzing...' : 'Gallery Result' }}
|
|
|
- <span class="engine-label">NestJS ONNX · Lego 11</span>
|
|
|
- </h2>
|
|
|
- <div class="snap-result-wrapper">
|
|
|
- <canvas #snapCanvas class="snap-canvas"></canvas>
|
|
|
- @if (visionSocket.analyzing()) {
|
|
|
- <div class="snap-overlay-spinner">
|
|
|
- <div class="spinner"></div>
|
|
|
- <p>Waiting for NestJS...</p>
|
|
|
- </div>
|
|
|
- }
|
|
|
+ } @else {
|
|
|
+ <div class="no-results">
|
|
|
+ Select images from Gallery and click "Start Batch Audit".
|
|
|
</div>
|
|
|
- <button class="btn btn-outline snap-retry" (click)="snappedFrame = null; socketGalleryFile = null">
|
|
|
- ← Choose Another Image
|
|
|
- </button>
|
|
|
}
|
|
|
}
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- ── Audit Manifest Table ──────────────────────────────────────────────── -->
|
|
|
- @if (completedReport() && !selectedAuditEntry()) {
|
|
|
- <div class="container audit-section">
|
|
|
- <h3 class="audit-title">Session Manifest — {{ completedReport()!.session_id }}</h3>
|
|
|
- <div class="audit-table-wrapper">
|
|
|
- <table class="audit-table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>ID</th>
|
|
|
- <th>Status</th>
|
|
|
- <th>Inference</th>
|
|
|
- <th>Action</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- @for (entry of completedReport()!.results; track entry.image_id) {
|
|
|
- <tr [class.row-error]="entry.status === 'error'">
|
|
|
- <td class="cell-id">{{ entry.image_id }}</td>
|
|
|
- <td>
|
|
|
- <span class="status-pill" [class.pill-ok]="entry.status === 'ok'" [class.pill-err]="entry.status === 'error'">
|
|
|
- {{ entry.status === 'ok' ? '✅ OK' : '❌ ERROR' }}
|
|
|
- </span>
|
|
|
- </td>
|
|
|
- <td class="cell-ms">{{ entry.status === 'ok' ? (entry.performance.inference_ms | number:'1.1-1') + ' ms' : '—' }}</td>
|
|
|
- <td>
|
|
|
- <button class="btn-evidence" (click)="selectedAuditEntry.set(entry)">View Evidence</button>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- }
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- }
|
|
|
-
|
|
|
- <!-- ── Evidence Drill-Down Panel ─────────────────────────────────────────── -->
|
|
|
- @if (selectedAuditEntry()) {
|
|
|
- <div class="container evidence-panel">
|
|
|
- <div class="evidence-header">
|
|
|
- <div>
|
|
|
- <span class="evidence-title">Technical Evidence</span>
|
|
|
- <span class="evidence-id">{{ selectedAuditEntry()!.image_id }}</span>
|
|
|
- </div>
|
|
|
- <button class="btn btn-outline btn-sm" (click)="selectedAuditEntry.set(null)">← Back to Summary</button>
|
|
|
+ <!-- Evidence JSON blocks — shown below the canvas when an entry is selected -->
|
|
|
+ @if (selectedAuditEntry()) {
|
|
|
+ <div class="evidence-panel">
|
|
|
+ <div class="evidence-header">
|
|
|
+ <div>
|
|
|
+ <span class="evidence-title">Technical Evidence</span>
|
|
|
+ <span class="evidence-id">{{ selectedAuditEntry()!.image_id }}</span>
|
|
|
+ </div>
|
|
|
+ <button class="btn btn-outline btn-sm" (click)="selectedAuditEntry.set(null)">✕ Close</button>
|
|
|
+ </div>
|
|
|
+ <div class="evidence-grid">
|
|
|
+ <div class="evidence-block">
|
|
|
+ <div class="evidence-block-label">Raw Tensor Sample</div>
|
|
|
+ <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence.raw_tensor_sample | json }}</pre>
|
|
|
+ </div>
|
|
|
+ <div class="evidence-block">
|
|
|
+ <div class="evidence-block-label">Industrial Summary</div>
|
|
|
+ <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence.industrial_summary | json }}</pre>
|
|
|
+ </div>
|
|
|
+ <div class="evidence-block evidence-block--full">
|
|
|
+ <div class="evidence-block-label">Full technical_evidence</div>
|
|
|
+ <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence | json }}</pre>
|
|
|
+ </div>
|
|
|
+ <div class="evidence-block evidence-block--full">
|
|
|
+ <div class="evidence-block-label">Performance</div>
|
|
|
+ <pre class="json-viewer">{{ selectedAuditEntry()!.performance | json }}</pre>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
</div>
|
|
|
|
|
|
- <!-- Evidence Canvas — image with bounding boxes drawn by drawEvidence() -->
|
|
|
- @if (selectedAuditEntry()!.localBlobUrl) {
|
|
|
- <div class="evidence-canvas-wrapper">
|
|
|
- <canvas #evidenceCanvas class="evidence-canvas"></canvas>
|
|
|
- </div>
|
|
|
- }
|
|
|
-
|
|
|
- <div class="evidence-grid">
|
|
|
- <div class="evidence-block">
|
|
|
- <div class="evidence-block-label">Raw Tensor Sample (pre-NMS · 5 rows · [x1, y1, x2, y2, conf, cls])</div>
|
|
|
- <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence.raw_tensor_sample | json }}</pre>
|
|
|
- </div>
|
|
|
+ <!-- ── COL 3: Live Audit Manifest ────────────────────────────────────── -->
|
|
|
+ <div class="dash-manifest">
|
|
|
+ <div class="glass-panel manifest-panel">
|
|
|
+ <div class="manifest-header">
|
|
|
+ <span class="manifest-title">Audit Manifest</span>
|
|
|
+ @if (completedReport()) {
|
|
|
+ <span class="manifest-session-id">{{ completedReport()!.session_id | slice:0:8 }}…</span>
|
|
|
+ } @else if (isBatchActive()) {
|
|
|
+ <span class="manifest-live-badge">LIVE</span>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="evidence-block">
|
|
|
- <div class="evidence-block-label">Industrial Summary</div>
|
|
|
- <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence.industrial_summary | json }}</pre>
|
|
|
- </div>
|
|
|
+ @if (!completedReport() && !isBatchActive()) {
|
|
|
+ <div class="manifest-empty">
|
|
|
+ Results will appear here as each image is processed.
|
|
|
+ </div>
|
|
|
+ }
|
|
|
|
|
|
- <div class="evidence-block evidence-block--full">
|
|
|
- <div class="evidence-block-label">Full technical_evidence</div>
|
|
|
- <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence | json }}</pre>
|
|
|
- </div>
|
|
|
+ <!-- Live rows during batch run -->
|
|
|
+ @if (isBatchActive() && sessionManifest.length > 0) {
|
|
|
+ <div class="manifest-table-scroll">
|
|
|
+ <table class="audit-table">
|
|
|
+ <thead>
|
|
|
+ <tr><th>Image</th><th>Status</th><th>ms</th></tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ @for (entry of sessionManifest; track entry.image_id) {
|
|
|
+ <tr [class.row-error]="entry.status === 'error'">
|
|
|
+ <td class="cell-id">{{ entry.image_id }}</td>
|
|
|
+ <td>
|
|
|
+ <span class="status-pill" [class.pill-ok]="entry.status === 'ok'" [class.pill-err]="entry.status === 'error'">
|
|
|
+ {{ entry.status === 'ok' ? '✅' : '❌' }}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td class="cell-ms">{{ entry.status === 'ok' ? (entry.performance.inference_ms | number:'1.0-0') : '—' }}</td>
|
|
|
+ </tr>
|
|
|
+ }
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
|
|
|
- <div class="evidence-block evidence-block--full">
|
|
|
- <div class="evidence-block-label">Performance</div>
|
|
|
- <pre class="json-viewer">{{ selectedAuditEntry()!.performance | json }}</pre>
|
|
|
+ <!-- Final manifest after completion -->
|
|
|
+ @if (completedReport()) {
|
|
|
+ <div class="manifest-table-scroll">
|
|
|
+ <table class="audit-table">
|
|
|
+ <thead>
|
|
|
+ <tr><th>Image</th><th>Status</th><th>ms</th><th></th></tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ @for (entry of completedReport()!.results; track entry.image_id) {
|
|
|
+ <tr [class.row-error]="entry.status === 'error'"
|
|
|
+ [class.row-selected]="selectedAuditEntry()?.image_id === entry.image_id"
|
|
|
+ (click)="selectedAuditEntry.set(entry)">
|
|
|
+ <td class="cell-id">{{ entry.image_id }}</td>
|
|
|
+ <td>
|
|
|
+ <span class="status-pill" [class.pill-ok]="entry.status === 'ok'" [class.pill-err]="entry.status === 'error'">
|
|
|
+ {{ entry.status === 'ok' ? '✅' : '❌' }}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td class="cell-ms">{{ entry.status === 'ok' ? (entry.performance.inference_ms | number:'1.0-0') : '—' }}</td>
|
|
|
+ <td>
|
|
|
+ @if (entry.localBlobUrl) {
|
|
|
+ <button class="btn-evidence" (click)="selectedAuditEntry.set(entry); $event.stopPropagation()">View</button>
|
|
|
+ }
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ }
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
</div>
|
|
|
</div>
|
|
|
+
|
|
|
</div>
|
|
|
- }
|
|
|
+ </div>
|
|
|
}
|