|
|
@@ -66,6 +66,8 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
@ViewChild('resultCanvas') resultCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
/** Snapshot canvas for socket-engine mode (frozen frame + bounding boxes) */
|
|
|
@ViewChild('snapCanvas') snapCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
+ /** Evidence canvas for batch audit drill-down (separate from snapCanvas) */
|
|
|
+ @ViewChild('evidenceCanvas') evidenceCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
/** Live webcam video feed */
|
|
|
@ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
|
|
|
|
|
|
@@ -100,6 +102,8 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
/** Total files in the current batch run — stored separately so it survives queue drain */
|
|
|
private _totalBatchCount = 0;
|
|
|
get totalBatchCount(): number { return this._totalBatchCount; }
|
|
|
+ /** Blob URL for the file currently being processed — stored so the pong effect can attach it to the record */
|
|
|
+ private _currentBlobUrl: string | null = null;
|
|
|
|
|
|
// ── Socket-engine state ────────────────────────────────────────────────────
|
|
|
/** 'webcam' = live camera snap | 'gallery' = file from device storage */
|
|
|
@@ -169,7 +173,9 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
industrial_summary: res.industrial_summary,
|
|
|
raw_tensor_sample: res.technical_evidence?.raw_tensor_sample ?? res.raw_tensor_sample ?? [],
|
|
|
},
|
|
|
+ localBlobUrl: this._currentBlobUrl ?? undefined,
|
|
|
};
|
|
|
+ this._currentBlobUrl = null;
|
|
|
this.sessionManifest.push(record);
|
|
|
|
|
|
const next = idx + 1;
|
|
|
@@ -219,6 +225,14 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
this.finalizeBatch();
|
|
|
}
|
|
|
});
|
|
|
+
|
|
|
+ // Draw evidence canvas whenever the user selects an audit entry
|
|
|
+ effect(() => {
|
|
|
+ const entry = this.selectedAuditEntry();
|
|
|
+ if (!entry) return;
|
|
|
+ // defer one tick so #evidenceCanvas is rendered in the DOM
|
|
|
+ setTimeout(() => this.drawEvidence(entry), 0);
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
ngOnInit(): void {}
|
|
|
@@ -277,29 +291,25 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
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; }
|
|
|
+ // Create a blob URL for the evidence canvas (revoked in finalizeBatch/abortBatch)
|
|
|
+ const blobUrl = URL.createObjectURL(file);
|
|
|
+ this._currentBlobUrl = blobUrl;
|
|
|
|
|
|
- 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;
|
|
|
+ const img = new Image();
|
|
|
+ img.onerror = () => { URL.revokeObjectURL(blobUrl); 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);
|
|
|
};
|
|
|
- reader.onerror = () => this.advanceBatchOnError(file.name, 'FileReader error');
|
|
|
- reader.readAsDataURL(file);
|
|
|
+ img.src = blobUrl;
|
|
|
}
|
|
|
|
|
|
+
|
|
|
/** Log a client-side read/decode error and advance to the next file */
|
|
|
private advanceBatchOnError(fileName: string, reason: string): void {
|
|
|
const record: BatchResult = {
|
|
|
@@ -359,11 +369,49 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
|
|
|
abortBatch(): void {
|
|
|
+ // Revoke any pending blob URL for the in-flight image
|
|
|
+ if (this._currentBlobUrl) { URL.revokeObjectURL(this._currentBlobUrl); this._currentBlobUrl = null; }
|
|
|
+ // Revoke all stored evidence URLs
|
|
|
+ this.sessionManifest.forEach(r => { if (r.localBlobUrl) URL.revokeObjectURL(r.localBlobUrl); });
|
|
|
this.batchQueue.set([]);
|
|
|
this.currentBatchIndex.set(0);
|
|
|
this._batchRunning.set(false);
|
|
|
}
|
|
|
|
|
|
+ /** Draw the source image + bounding boxes onto the dedicated evidence canvas */
|
|
|
+ private drawEvidence(entry: BatchResult): void {
|
|
|
+ const canvas = this.evidenceCanvasRef?.nativeElement;
|
|
|
+ if (!canvas || !entry.localBlobUrl) return;
|
|
|
+
|
|
|
+ const img = new Image();
|
|
|
+ img.onload = () => {
|
|
|
+ const size = canvas.parentElement!.clientWidth || 640;
|
|
|
+ canvas.width = size;
|
|
|
+ canvas.height = size;
|
|
|
+ const ctx = canvas.getContext('2d')!;
|
|
|
+ ctx.drawImage(img, 0, 0, size, size);
|
|
|
+
|
|
|
+ const scaleX = size / 640;
|
|
|
+ const scaleY = size / 640;
|
|
|
+
|
|
|
+ entry.detections.forEach(det => {
|
|
|
+ const { x1, y1, x2, y2 } = det.bounding_box;
|
|
|
+ const color = GRADE_COLORS[det.ripeness_class] || '#00A651';
|
|
|
+ ctx.strokeStyle = color;
|
|
|
+ ctx.lineWidth = 3;
|
|
|
+ ctx.strokeRect(x1 * scaleX, y1 * scaleY, (x2 - x1) * scaleX, (y2 - y1) * scaleY);
|
|
|
+ ctx.fillStyle = color;
|
|
|
+ ctx.font = 'bold 13px Outfit';
|
|
|
+ const label = `${det.ripeness_class} ${det.confidence_pct.toFixed(1)}%`;
|
|
|
+ const tw = ctx.measureText(label).width;
|
|
|
+ ctx.fillRect(x1 * scaleX, y1 * scaleY - 22, tw + 8, 22);
|
|
|
+ ctx.fillStyle = '#fff';
|
|
|
+ ctx.fillText(label, x1 * scaleX + 4, y1 * scaleY - 6);
|
|
|
+ });
|
|
|
+ };
|
|
|
+ img.src = entry.localBlobUrl;
|
|
|
+ }
|
|
|
+
|
|
|
onDragOver(event: DragEvent): void {
|
|
|
event.preventDefault();
|
|
|
event.stopPropagation();
|
|
|
@@ -690,4 +738,25 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
getSummaryKeys(): string[] {
|
|
|
return Object.keys(this.inferenceService.summary() ?? {});
|
|
|
}
|
|
|
+
|
|
|
+ /** Aggregate industrial_summary across all successful batch results */
|
|
|
+ getBatchDistribution(): { class: string; count: number; pct: number; color: string }[] {
|
|
|
+ const report = this.completedReport();
|
|
|
+ if (!report) return [];
|
|
|
+ const totals: Record<string, number> = {};
|
|
|
+ report.results.filter(r => r.status === 'ok').forEach(r => {
|
|
|
+ Object.entries(r.technical_evidence.industrial_summary).forEach(([cls, n]) => {
|
|
|
+ totals[cls] = (totals[cls] ?? 0) + n;
|
|
|
+ });
|
|
|
+ });
|
|
|
+ const grand = Object.values(totals).reduce((s, n) => s + n, 0) || 1;
|
|
|
+ return Object.entries(totals)
|
|
|
+ .sort((a, b) => b[1] - a[1])
|
|
|
+ .map(([cls, count]) => ({
|
|
|
+ class: cls,
|
|
|
+ count,
|
|
|
+ pct: Math.round((count / grand) * 100),
|
|
|
+ color: GRADE_COLORS[cls] ?? '#888',
|
|
|
+ }));
|
|
|
+ }
|
|
|
}
|