瀏覽代碼

batch processing. Allow multi image selection

Dr-Swopt 2 天之前
父節點
當前提交
9578f83655

+ 86 - 9
src/app/components/analyzer/analyzer.component.html

@@ -247,23 +247,30 @@
 
           <!-- Gallery controls -->
           @if (socketInputMode() === 'gallery') {
-            <div class="gallery-pick"
+            <div class="glass-panel upload-zone gallery-upload-zone"
                  (click)="galleryInput.click()"
-                 [class.has-file]="socketGalleryFile">
-              <input type="file" #galleryInput accept="image/*"
+                 [class.has-file]="socketGalleryFile"
+                 [class.dragging]="isDragging"
+                 (dragover)="onDragOver($event)"
+                 (dragleave)="onDragLeave($event)"
+                 (drop)="onGalleryDrop($event)">
+              <input type="file" #galleryInput accept="image/*" multiple
                      (change)="onGalleryFileSelected($event)" style="display:none">
-              <span class="gallery-pick__icon">🖼</span>
-              @if (!socketGalleryFile) {
-                <span>Tap to choose image from gallery</span>
+              <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>
+              } @else if (batchQueue().length > 0) {
+                <p>{{ batchQueue().length }} image(s) queued for batch</p>
               } @else {
-                <span>{{ socketGalleryFile.name }}</span>
+                <p>{{ socketGalleryFile!.name }}</p>
               }
             </div>
             <button
               class="btn btn-primary snap-btn"
               (click)="analyzeGalleryImage()"
-              [disabled]="!socketGalleryFile || visionSocket.analyzing() || !visionSocket.connected()">
-              {{ visionSocket.analyzing() ? 'Analyzing...' : '🔍 Analyze Image' }}
+              [disabled]="(!socketGalleryFile && batchQueue().length === 0) || visionSocket.analyzing() || !visionSocket.connected()">
+              {{ visionSocket.analyzing() ? 'Analyzing...' : (batchQueue().length > 1 ? '🚀 Start Batch Audit' : '🔍 Analyze Image') }}
             </button>
           }
 
@@ -363,4 +370,74 @@
 
     </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>
+      </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>
+
+        <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>
+  }
 }

+ 194 - 0
src/app/components/analyzer/analyzer.component.scss

@@ -428,6 +428,21 @@
   flex-shrink: 0;
 }
 
+// ── Gallery batch upload zone ──────────────────────────────────────────────────
+.gallery-upload-zone {
+  height: auto;
+  min-height: 140px;
+  padding: 20px;
+
+  p { margin: 4px 0; }
+}
+
+.upload-hint {
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  margin-top: 4px !important;
+}
+
 // ── Engine info / labels ───────────────────────────────────────────────────────
 .engine-info {
   display: flex;
@@ -562,6 +577,185 @@
   &:hover { background: #b02a37; }
 }
 
+// ── Audit Manifest Table ──────────────────────────────────────────────────
+.audit-section {
+  margin-top: 32px;
+  padding-bottom: 40px;
+}
+
+.audit-title {
+  font-size: 0.78rem;
+  font-weight: 700;
+  color: var(--accent-gold);
+  text-transform: uppercase;
+  letter-spacing: 0.07em;
+  margin-bottom: 12px;
+}
+
+.audit-table-wrapper {
+  max-height: 420px;
+  overflow-y: auto;
+  border: 1px solid var(--border-color);
+  border-radius: 10px;
+}
+
+.audit-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 0.8rem;
+
+  thead {
+    position: sticky;
+    top: 0;
+    background: var(--input-bg);
+    z-index: 1;
+
+    th {
+      padding: 10px 14px;
+      text-align: left;
+      font-size: 0.7rem;
+      font-weight: 700;
+      color: var(--text-secondary);
+      text-transform: uppercase;
+      letter-spacing: 0.06em;
+      border-bottom: 1px solid var(--border-color);
+    }
+  }
+
+  tbody tr {
+    border-bottom: 1px solid var(--border-color);
+    transition: background 0.15s ease;
+
+    &:last-child { border-bottom: none; }
+    &:hover { background: rgba(255, 255, 255, 0.03); }
+    &.row-error { background: rgba(220, 53, 69, 0.05); }
+  }
+
+  td {
+    padding: 9px 14px;
+    color: var(--text-primary);
+    vertical-align: middle;
+  }
+}
+
+.cell-id {
+  font-family: monospace;
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  max-width: 220px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.cell-ms {
+  font-variant-numeric: tabular-nums;
+  color: var(--accent-green);
+  font-size: 0.78rem;
+}
+
+.status-pill {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 10px;
+  font-size: 0.7rem;
+  font-weight: 700;
+  background: var(--input-bg);
+  border: 1px solid var(--border-color);
+
+  &.pill-ok  { border-color: var(--accent-green); color: var(--accent-green); }
+  &.pill-err { border-color: var(--danger); color: var(--danger); }
+}
+
+.btn-evidence {
+  padding: 4px 10px;
+  font-size: 0.72rem;
+  font-weight: 600;
+  font-family: var(--font-main);
+  background: transparent;
+  border: 1px solid var(--accent-gold);
+  color: var(--accent-gold);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: background 0.15s ease;
+
+  &:hover { background: rgba(255, 215, 0, 0.08); }
+}
+
+// ── Evidence Drill-Down Panel ─────────────────────────────────────────────
+.evidence-panel {
+  margin-top: 32px;
+  padding-bottom: 40px;
+}
+
+.evidence-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-bottom: 20px;
+}
+
+.evidence-title {
+  font-size: 1rem;
+  font-weight: 700;
+  color: var(--accent-gold);
+  margin-right: 12px;
+}
+
+.evidence-id {
+  font-size: 0.78rem;
+  font-family: monospace;
+  color: var(--text-secondary);
+}
+
+.evidence-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+
+  @media (max-width: 768px) {
+    grid-template-columns: 1fr;
+  }
+}
+
+.evidence-block {
+  background: var(--input-bg);
+  border: 1px solid var(--border-color);
+  border-radius: 10px;
+  padding: 14px;
+
+  &--full {
+    grid-column: 1 / -1;
+  }
+}
+
+.evidence-block-label {
+  font-size: 0.68rem;
+  font-weight: 700;
+  color: var(--accent-green);
+  text-transform: uppercase;
+  letter-spacing: 0.07em;
+  margin-bottom: 10px;
+}
+
+.json-viewer {
+  margin: 0;
+  padding: 12px;
+  background: #0d0d0d;
+  border-radius: 6px;
+  border: 1px solid rgba(255, 255, 255, 0.06);
+  font-family: 'Courier New', monospace;
+  font-size: 0.72rem;
+  color: #a8d8a8;
+  white-space: pre-wrap;
+  word-break: break-all;
+  max-height: 260px;
+  overflow-y: auto;
+  line-height: 1.5;
+}
+
 // ── Batch Complete HUD ────────────────────────────────────────────────────────
 .batch-complete-hud {
   padding: 14px 16px;

+ 42 - 7
src/app/components/analyzer/analyzer.component.ts

@@ -36,7 +36,6 @@ import {
 } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { ImageProcessorService } from '../../services/image-processor.service';
 import { LocalHistoryService } from '../../services/local-history.service';
 import { InferenceService, LocalEngine } from '../../core/services/inference.service';
 import { VisionSocketService } from '../../services/vision-socket.service';
@@ -87,10 +86,12 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   // ── Batch ingestion state (backend/socket mode only) ───────────────────────
   batchQueue = signal<File[]>([]);
   currentBatchIndex = signal<number>(0);
-  isBatchActive = computed(() => this.batchQueue().length > 0);
+  private _batchRunning = signal<boolean>(false);
+  isBatchActive = computed(() => this._batchRunning() && this.batchQueue().length > 0);
 
   sessionManifest: BatchResult[] = [];
   completedReport = signal<FullSessionReport | null>(null);
+  selectedAuditEntry = signal<BatchResult | null>(null);
 
   /** Wall-clock timestamp of when sendBase64 was called for the current image */
   private _pingTime = 0;
@@ -110,7 +111,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   private webcamStream: MediaStream | null = null;
 
   constructor(
-    private imageProcessor: ImageProcessorService,
     public inferenceService: InferenceService,
     private localHistory: LocalHistoryService,
     public visionSocket: VisionSocketService,
@@ -254,7 +254,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
       this.processSingleFile(files[0]);
     } else {
       this.batchQueue.set(Array.from(files));
-      this.currentBatchIndex.set(0);
       this.sessionManifest = [];
       this.startBatchProcessing();
     }
@@ -270,6 +269,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     this.sessionManifest = [];
     this.completedReport.set(null);
     this.currentBatchIndex.set(0);
+    this._batchRunning.set(true);
     this.processNextInBatch();
   }
 
@@ -355,11 +355,13 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     this.completedReport.set(report);
     this.batchQueue.set([]);
     this.currentBatchIndex.set(0);
+    this._batchRunning.set(false);
   }
 
   abortBatch(): void {
     this.batchQueue.set([]);
     this.currentBatchIndex.set(0);
+    this._batchRunning.set(false);
   }
 
   onDragOver(event: DragEvent): void {
@@ -512,14 +514,47 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
 
   onGalleryFileSelected(event: Event): void {
     const input = event.target as HTMLInputElement;
-    const file = input.files?.[0];
-    if (!file) return;
-    this.socketGalleryFile = file;
+    const files = input.files;
+    if (!files || files.length === 0) return;
+    this.snappedFrame = null;
+    this.visionSocket.clearResult();
+    if (files.length > 1) {
+      this.batchQueue.set(Array.from(files));
+      this.currentBatchIndex.set(0);
+      this.sessionManifest = [];
+      this.socketGalleryFile = null;
+    } else {
+      this.socketGalleryFile = files[0];
+      this.batchQueue.set([]);
+    }
+    // Reset input so the same files can be re-selected if needed
+    input.value = '';
+  }
+
+  onGalleryDrop(event: DragEvent): void {
+    event.preventDefault();
+    event.stopPropagation();
+    this.isDragging = false;
+    const files = event.dataTransfer?.files;
+    if (!files || files.length === 0) return;
     this.snappedFrame = null;
     this.visionSocket.clearResult();
+    if (files.length > 1) {
+      this.batchQueue.set(Array.from(files).filter(f => f.type.startsWith('image/')));
+      this.currentBatchIndex.set(0);
+      this.sessionManifest = [];
+      this.socketGalleryFile = null;
+    } else {
+      this.socketGalleryFile = files[0];
+      this.batchQueue.set([]);
+    }
   }
 
   analyzeGalleryImage(): void {
+    if (this.batchQueue().length > 1) {
+      this.startBatchProcessing();
+      return;
+    }
     if (!this.socketGalleryFile) return;
     const reader = new FileReader();
     reader.onload = (e) => {