ソースを参照

batch processing half way there

Dr-Swopt 2 日 前
コミット
af0a29710b

+ 39 - 1
src/app/components/analyzer/analyzer.component.html

@@ -147,7 +147,7 @@
             </span>
           </div>
 
-          <!-- Batch Status HUD — visible only when a batch queue is loaded -->
+          <!-- Batch Status HUD — active while queue is running -->
           @if (isBatchActive()) {
             <div class="batch-status-hud glass-panel">
               <div class="batch-status-label">
@@ -166,6 +166,44 @@
             </div>
           }
 
+          <!-- Batch Complete HUD — hardware report shown after finalizeBatch() -->
+          @if (completedReport()) {
+            <div class="batch-complete-hud glass-panel">
+              <div class="batch-complete-title">Batch Complete</div>
+              <div class="batch-complete-stats">
+                <div class="bc-stat">
+                  <span class="bc-label">Images</span>
+                  <span class="bc-value">{{ completedReport()!.meta.total_images }}</span>
+                </div>
+                <div class="bc-stat">
+                  <span class="bc-label">Successful</span>
+                  <span class="bc-value ok">{{ completedReport()!.meta.successful }}</span>
+                </div>
+                <div class="bc-stat">
+                  <span class="bc-label">Failed</span>
+                  <span class="bc-value" [class.alert]="completedReport()!.meta.failed > 0">
+                    {{ completedReport()!.meta.failed }}
+                  </span>
+                </div>
+                <div class="bc-stat highlight">
+                  <span class="bc-label">Avg Inference</span>
+                  <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-value">{{ completedReport()!.meta.avg_round_trip_ms }} ms</span>
+                </div>
+                <div class="bc-stat">
+                  <span class="bc-label">Total Time</span>
+                  <span class="bc-value">{{ completedReport()!.meta.total_time_ms }} ms</span>
+                </div>
+              </div>
+              <button class="btn btn-outline btn-sm" (click)="completedReport.set(null)">
+                Dismiss
+              </button>
+            </div>
+          }
+
           <!-- Input source toggle -->
           <div class="input-mode-toggle">
             <button

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

@@ -561,3 +561,50 @@
 
   &:hover { background: #b02a37; }
 }
+
+// ── Batch Complete HUD ────────────────────────────────────────────────────────
+.batch-complete-hud {
+  padding: 14px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  border: 1px solid var(--accent, #00A651);
+}
+
+.batch-complete-title {
+  font-size: 14px;
+  font-weight: 700;
+  color: var(--accent, #00A651);
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+}
+
+.batch-complete-stats {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 8px;
+}
+
+.bc-stat {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+
+  &.highlight .bc-value { color: var(--accent, #00A651); font-size: 18px; }
+}
+
+.bc-label {
+  font-size: 11px;
+  color: var(--text-secondary);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+
+.bc-value {
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--text-primary, #fff);
+
+  &.ok    { color: var(--accent, #00A651); }
+  &.alert { color: #DC3545; }
+}

+ 192 - 6
src/app/components/analyzer/analyzer.component.ts

@@ -41,6 +41,10 @@ import { LocalHistoryService } from '../../services/local-history.service';
 import { InferenceService, LocalEngine } from '../../core/services/inference.service';
 import { VisionSocketService } from '../../services/vision-socket.service';
 import { SurveillanceService } from '../../services/surveillance.service';
+import {
+  BatchResult,
+  FullSessionReport,
+} from '../../core/interfaces/palm-analysis.interface';
 
 export type SnapEngine = 'tflite' | 'onnx' | 'socket';
 
@@ -84,10 +88,17 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   batchQueue = signal<File[]>([]);
   currentBatchIndex = signal<number>(0);
   isBatchActive = computed(() => this.batchQueue().length > 0);
-  /** Accumulates per-image results for 4.2/4.3 audit trail */
-  sessionManifest: any[] = [];
 
-  get totalBatchCount(): number { return this.batchQueue().length + this.currentBatchIndex(); }
+  sessionManifest: BatchResult[] = [];
+  completedReport = signal<FullSessionReport | null>(null);
+
+  /** Wall-clock timestamp of when sendBase64 was called for the current image */
+  private _pingTime = 0;
+  /** Wall-clock timestamp of when startBatchProcessing was called */
+  private _batchStartTime = 0;
+  /** Total files in the current batch run — stored separately so it survives queue drain */
+  private _totalBatchCount = 0;
+  get totalBatchCount(): number { return this._totalBatchCount; }
 
   // ── Socket-engine state ────────────────────────────────────────────────────
   /** 'webcam' = live camera snap | 'gallery' = file from device storage */
@@ -120,6 +131,94 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
         this.abortBatch();
       }
     });
+
+    // "Pong" — fires on every lastResult change. Guards on non-null result AND
+    // active batch so it never interferes with single-image gallery/snap flows.
+    effect(() => {
+      const res = this.visionSocket.lastResult();
+      if (!res || !this.isBatchActive()) return;
+
+      const idx = this.currentBatchIndex();
+      const queue = this.batchQueue();
+      const roundTripMs = performance.now() - this._pingTime;
+
+      const record: BatchResult = {
+        image_id: queue[idx]?.name ?? `file_${idx}`,
+        timestamp: new Date().toISOString(),
+        status: 'ok',
+        detections: res.detections.map(d => ({
+          bunch_id: d.bunch_id,
+          ripeness_class: d.class,
+          confidence_pct: Math.round(d.confidence * 10000) / 100,
+          is_health_alert: d.is_health_alert,
+          bounding_box: { x1: d.box[0], y1: d.box[1], x2: d.box[2], y2: d.box[3] },
+          norm_box: d.norm_box
+            ? { x1: d.norm_box[0], y1: d.norm_box[1], x2: d.norm_box[2], y2: d.norm_box[3] }
+            : null,
+        })),
+        performance: {
+          inference_ms: res.inference_ms,
+          processing_ms: res.processing_ms,
+          round_trip_ms: Math.round(roundTripMs),
+        },
+        technical_evidence: {
+          engine: 'NestJS-ONNX',
+          archive_id: res.archive_id,
+          total_count: res.total_count,
+          threshold: res.current_threshold,
+          industrial_summary: res.industrial_summary,
+          raw_tensor_sample: res.technical_evidence?.raw_tensor_sample ?? res.raw_tensor_sample ?? [],
+        },
+      };
+      this.sessionManifest.push(record);
+
+      const next = idx + 1;
+      if (next < queue.length) {
+        this.currentBatchIndex.set(next);
+        this.processNextInBatch();
+      } else {
+        this.finalizeBatch();
+      }
+    });
+
+    // "Error pong" — if a socket error fires mid-batch, log it and advance
+    effect(() => {
+      const err = this.visionSocket.lastError();
+      if (!err || !this.isBatchActive()) return;
+
+      const idx = this.currentBatchIndex();
+      const queue = this.batchQueue();
+
+      const record: BatchResult = {
+        image_id: queue[idx]?.name ?? `file_${idx}`,
+        timestamp: new Date().toISOString(),
+        status: 'error',
+        detections: [],
+        performance: {
+          inference_ms: 0,
+          processing_ms: 0,
+          round_trip_ms: Math.round(performance.now() - this._pingTime),
+        },
+        technical_evidence: {
+          engine: 'NestJS-ONNX',
+          archive_id: '',
+          total_count: 0,
+          threshold: 0,
+          industrial_summary: {},
+          raw_tensor_sample: [],
+        },
+        error: err,
+      };
+      this.sessionManifest.push(record);
+
+      const next = idx + 1;
+      if (next < queue.length) {
+        this.currentBatchIndex.set(next);
+        this.processNextInBatch();
+      } else {
+        this.finalizeBatch();
+      }
+    });
   }
 
   ngOnInit(): void {}
@@ -165,10 +264,97 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     this.handleFile(file);
   }
 
-  /** Placeholder — actual network loop is Task 4.2. Do NOT send files here. */
   private startBatchProcessing(): void {
-    // Queue is loaded; progress HUD is now visible via isBatchActive().
-    // Task 4.2 will drive the sequential sendBase64() loop from here.
+    this._totalBatchCount = this.batchQueue().length;
+    this._batchStartTime = performance.now();
+    this.sessionManifest = [];
+    this.completedReport.set(null);
+    this.currentBatchIndex.set(0);
+    this.processNextInBatch();
+  }
+
+  private processNextInBatch(): void {
+    const file = this.batchQueue()[this.currentBatchIndex()];
+    if (!file) return;
+
+    // Render file → 640×640 offscreen canvas → JPEG Base64 (Lego 11 format)
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      const src = e.target?.result as string;
+      if (!src) { this.advanceBatchOnError(file.name, 'FileReader returned empty result'); return; }
+
+      const img = new Image();
+      img.onerror = () => this.advanceBatchOnError(file.name, 'Image failed to decode');
+      img.onload = () => {
+        const offscreen = document.createElement('canvas');
+        offscreen.width = 640;
+        offscreen.height = 640;
+        offscreen.getContext('2d')!.drawImage(img, 0, 0, 640, 640);
+        const base64 = offscreen.toDataURL('image/jpeg');
+        this._pingTime = performance.now();
+        this.visionSocket.sendBase64(base64);
+      };
+      img.src = src;
+    };
+    reader.onerror = () => this.advanceBatchOnError(file.name, 'FileReader error');
+    reader.readAsDataURL(file);
+  }
+
+  /** Log a client-side read/decode error and advance to the next file */
+  private advanceBatchOnError(fileName: string, reason: string): void {
+    const record: BatchResult = {
+      image_id: fileName,
+      timestamp: new Date().toISOString(),
+      status: 'error',
+      detections: [],
+      performance: { inference_ms: 0, processing_ms: 0, round_trip_ms: 0 },
+      technical_evidence: {
+        engine: 'NestJS-ONNX',
+        archive_id: '',
+        total_count: 0,
+        threshold: 0,
+        industrial_summary: {},
+        raw_tensor_sample: [],
+      },
+      error: reason,
+    };
+    this.sessionManifest.push(record);
+    const next = this.currentBatchIndex() + 1;
+    if (next < this.batchQueue().length) {
+      this.currentBatchIndex.set(next);
+      this.processNextInBatch();
+    } else {
+      this.finalizeBatch();
+    }
+  }
+
+  private finalizeBatch(): void {
+    const totalMs = Math.round(performance.now() - this._batchStartTime);
+    const ok = this.sessionManifest.filter(r => r.status === 'ok');
+    const avgInference = ok.length
+      ? Math.round(ok.reduce((s, r) => s + r.performance.inference_ms, 0) / ok.length)
+      : 0;
+    const avgRoundTrip = ok.length
+      ? Math.round(ok.reduce((s, r) => s + r.performance.round_trip_ms, 0) / ok.length)
+      : 0;
+
+    const report: FullSessionReport = {
+      session_id: crypto.randomUUID(),
+      generated_at: new Date().toISOString(),
+      meta: {
+        total_images: this._totalBatchCount,
+        successful: ok.length,
+        failed: this.sessionManifest.length - ok.length,
+        total_time_ms: totalMs,
+        avg_inference_ms: avgInference,
+        avg_round_trip_ms: avgRoundTrip,
+      },
+      results: this.sessionManifest,
+    };
+
+    this.completedReport.set(report);
+    this.batchQueue.set([]);
+    this.currentBatchIndex.set(0);
   }
 
   abortBatch(): void {

+ 57 - 0
src/app/core/interfaces/palm-analysis.interface.ts

@@ -1,3 +1,51 @@
+// ── Batch Audit Manifest types ────────────────────────────────────────────────
+
+export interface AuditDetection {
+  bunch_id: number;
+  ripeness_class: string;
+  confidence_pct: number;
+  is_health_alert: boolean;
+  bounding_box: { x1: number; y1: number; x2: number; y2: number };
+  norm_box: { x1: number; y1: number; x2: number; y2: number } | null;
+}
+
+export interface BatchResult {
+  image_id: string;
+  timestamp: string;           // ISO 8601
+  status: 'ok' | 'error';
+  detections: AuditDetection[];
+  performance: {
+    inference_ms: number;
+    processing_ms: number;
+    round_trip_ms: number;     // wall-clock from sendBase64 to pong
+  };
+  technical_evidence: {
+    engine: 'NestJS-ONNX';
+    archive_id: string;
+    total_count: number;
+    threshold: number;
+    industrial_summary: Record<string, number>;
+    raw_tensor_sample: number[][];
+  };
+  error?: string;
+}
+
+export interface FullSessionReport {
+  session_id: string;
+  generated_at: string;        // ISO 8601
+  meta: {
+    total_images: number;
+    successful: number;
+    failed: number;
+    total_time_ms: number;
+    avg_inference_ms: number;
+    avg_round_trip_ms: number;
+  };
+  results: BatchResult[];
+}
+
+// ── Existing interfaces ───────────────────────────────────────────────────────
+
 export interface BoundingBox {
   x1: number;
   y1: number;
@@ -27,4 +75,13 @@ export interface AnalysisResponse {
   inference_ms: number;
   processing_ms: number;
   archive_id: string;
+  raw_tensor_sample?: number[][];
+  technical_evidence?: {
+    engine: 'NestJS-ONNX';
+    archive_id: string;
+    total_count: number;
+    threshold: number;
+    industrial_summary: IndustrialSummary;
+    raw_tensor_sample: number[][];
+  };
 }