|
@@ -1,25 +1,23 @@
|
|
|
import {
|
|
import {
|
|
|
- AfterViewInit,
|
|
|
|
|
Component,
|
|
Component,
|
|
|
ElementRef,
|
|
ElementRef,
|
|
|
OnDestroy,
|
|
OnDestroy,
|
|
|
OnInit,
|
|
OnInit,
|
|
|
ViewChild,
|
|
ViewChild,
|
|
|
} from '@angular/core';
|
|
} from '@angular/core';
|
|
|
-import { AsyncPipe, DecimalPipe, KeyValuePipe, NgClass, NgStyle } from '@angular/common';
|
|
|
|
|
|
|
+import { AsyncPipe, NgClass } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
import { FormsModule } from '@angular/forms';
|
|
|
import { Select, Store } from '@ngxs/store';
|
|
import { Select, Store } from '@ngxs/store';
|
|
|
import { Observable, Subscription } from 'rxjs';
|
|
import { Observable, Subscription } from 'rxjs';
|
|
|
-import { filter } from 'rxjs/operators';
|
|
|
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
|
-import { MatCardModule } from '@angular/material/card';
|
|
|
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
|
import { VisionState } from '../store/vision.state';
|
|
import { VisionState } from '../store/vision.state';
|
|
|
import { SubmitBatchAnalysis } from '../store/vision.actions';
|
|
import { SubmitBatchAnalysis } from '../store/vision.actions';
|
|
|
import { InferenceFrame } from '../services/inference.service';
|
|
import { InferenceFrame } from '../services/inference.service';
|
|
|
|
|
+import { BatchReportComponent } from '../history/batch-report/batch-report.component';
|
|
|
|
|
|
|
|
type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
|
|
type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
|
|
|
|
|
|
|
@@ -28,35 +26,29 @@ type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
|
|
|
standalone: true,
|
|
standalone: true,
|
|
|
imports: [
|
|
imports: [
|
|
|
AsyncPipe,
|
|
AsyncPipe,
|
|
|
- DecimalPipe,
|
|
|
|
|
- KeyValuePipe,
|
|
|
|
|
NgClass,
|
|
NgClass,
|
|
|
- NgStyle,
|
|
|
|
|
FormsModule,
|
|
FormsModule,
|
|
|
MatSelectModule,
|
|
MatSelectModule,
|
|
|
MatFormFieldModule,
|
|
MatFormFieldModule,
|
|
|
MatButtonModule,
|
|
MatButtonModule,
|
|
|
MatProgressSpinnerModule,
|
|
MatProgressSpinnerModule,
|
|
|
- MatCardModule,
|
|
|
|
|
MatIconModule,
|
|
MatIconModule,
|
|
|
|
|
+ BatchReportComponent,
|
|
|
],
|
|
],
|
|
|
templateUrl: './analyzer.component.html',
|
|
templateUrl: './analyzer.component.html',
|
|
|
styleUrl: './analyzer.component.scss',
|
|
styleUrl: './analyzer.component.scss',
|
|
|
})
|
|
})
|
|
|
-export class AnalyzerComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
|
|
|
|
|
+export class AnalyzerComponent implements OnInit, OnDestroy {
|
|
|
@Select(VisionState.loading) loading$!: Observable<boolean>;
|
|
@Select(VisionState.loading) loading$!: Observable<boolean>;
|
|
|
|
|
|
|
|
- @ViewChild('resultCanvas') resultCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
|
|
@ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
|
|
@ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
|
|
|
|
|
|
|
|
mode: EngineMode = 'remote';
|
|
mode: EngineMode = 'remote';
|
|
|
- isViewInitialized = false;
|
|
|
|
|
inputMode: 'file' | 'camera' = 'file';
|
|
inputMode: 'file' | 'camera' = 'file';
|
|
|
isDragOver = false;
|
|
isDragOver = false;
|
|
|
isCameraActive = false;
|
|
isCameraActive = false;
|
|
|
- currentFrame: InferenceFrame | null = null;
|
|
|
|
|
batchFrames: InferenceFrame[] = [];
|
|
batchFrames: InferenceFrame[] = [];
|
|
|
- selectedFrameIndex = 0;
|
|
|
|
|
|
|
+ activeBatchId: string | null = null;
|
|
|
|
|
|
|
|
readonly engineOptions: { value: EngineMode; label: string; icon: string }[] = [
|
|
readonly engineOptions: { value: EngineMode; label: string; icon: string }[] = [
|
|
|
{ value: 'local-onnx', label: 'Local — ONNX WASM', icon: 'memory' },
|
|
{ value: 'local-onnx', label: 'Local — ONNX WASM', icon: 'memory' },
|
|
@@ -65,38 +57,20 @@ export class AnalyzerComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
private mediaStream: MediaStream | null = null;
|
|
private mediaStream: MediaStream | null = null;
|
|
|
- private inferenceSub!: Subscription;
|
|
|
|
|
private batchSub!: Subscription;
|
|
private batchSub!: Subscription;
|
|
|
|
|
|
|
|
constructor(private store: Store) {}
|
|
constructor(private store: Store) {}
|
|
|
|
|
|
|
|
ngOnInit(): void {
|
|
ngOnInit(): void {
|
|
|
- this.inferenceSub = (this.store.select(VisionState.currentInference) as Observable<InferenceFrame | null>)
|
|
|
|
|
- .pipe(filter((f): f is InferenceFrame => !!f))
|
|
|
|
|
- .subscribe(frame => {
|
|
|
|
|
- this.currentFrame = frame;
|
|
|
|
|
- if (this.isViewInitialized) {
|
|
|
|
|
- this.renderPredictionsWithBoxes(frame);
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
this.batchSub = (this.store.select((state: any) => state.visionState?.batchFrames ?? []) as Observable<InferenceFrame[]>)
|
|
this.batchSub = (this.store.select((state: any) => state.visionState?.batchFrames ?? []) as Observable<InferenceFrame[]>)
|
|
|
.subscribe(frames => {
|
|
.subscribe(frames => {
|
|
|
this.batchFrames = frames;
|
|
this.batchFrames = frames;
|
|
|
- this.selectedFrameIndex = 0;
|
|
|
|
|
|
|
+ this.activeBatchId = frames.length > 0 ? (frames[0]?.batchId ?? null) : null;
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- ngAfterViewInit(): void {
|
|
|
|
|
- this.isViewInitialized = true;
|
|
|
|
|
- if (this.currentFrame) {
|
|
|
|
|
- this.renderPredictionsWithBoxes(this.currentFrame);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
ngOnDestroy(): void {
|
|
ngOnDestroy(): void {
|
|
|
this.stopCamera();
|
|
this.stopCamera();
|
|
|
- this.inferenceSub?.unsubscribe();
|
|
|
|
|
this.batchSub?.unsubscribe();
|
|
this.batchSub?.unsubscribe();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -172,99 +146,12 @@ export class AnalyzerComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
|
}, 'image/jpeg');
|
|
}, 'image/jpeg');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ── Canvas renderer ───────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
- renderPredictionsWithBoxes(frame: InferenceFrame): void {
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- const canvas = this.resultCanvasRef?.nativeElement;
|
|
|
|
|
- if (!canvas || !frame.imageDataUrl) return;
|
|
|
|
|
-
|
|
|
|
|
- const img = new Image();
|
|
|
|
|
- img.onload = () => {
|
|
|
|
|
- const container = this.resultCanvasRef.nativeElement.parentElement;
|
|
|
|
|
- const targetWidth = container?.clientWidth || img.naturalWidth;
|
|
|
|
|
- const targetHeight = container?.clientHeight || img.naturalHeight;
|
|
|
|
|
- const scale = Math.min(targetWidth / img.naturalWidth, targetHeight / img.naturalHeight);
|
|
|
|
|
-
|
|
|
|
|
- canvas.width = img.naturalWidth * scale;
|
|
|
|
|
- canvas.height = img.naturalHeight * scale;
|
|
|
|
|
- const ctx = canvas.getContext('2d')!;
|
|
|
|
|
- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
-
|
|
|
|
|
- for (const det of frame.detections) {
|
|
|
|
|
- let x: number, y: number, w: number, h: number;
|
|
|
|
|
- if (det.norm_box) {
|
|
|
|
|
- const [nx1, ny1, nx2, ny2] = det.norm_box;
|
|
|
|
|
- x = nx1 * canvas.width;
|
|
|
|
|
- y = ny1 * canvas.height;
|
|
|
|
|
- w = (nx2 - nx1) * canvas.width;
|
|
|
|
|
- h = (ny2 - ny1) * canvas.height;
|
|
|
|
|
- } else if (det.box) {
|
|
|
|
|
- const [x1, y1, x2, y2] = det.box;
|
|
|
|
|
- x = x1 * scale;
|
|
|
|
|
- y = y1 * scale;
|
|
|
|
|
- w = (x2 - x1) * scale;
|
|
|
|
|
- h = (y2 - y1) * scale;
|
|
|
|
|
- } else {
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- const color = this.gradeColor(det.class);
|
|
|
|
|
-
|
|
|
|
|
- ctx.strokeStyle = color;
|
|
|
|
|
- ctx.lineWidth = 2;
|
|
|
|
|
- ctx.strokeRect(x, y, w, h);
|
|
|
|
|
-
|
|
|
|
|
- const label = `${det.class} ${(det.confidence * 100).toFixed(1)}%`;
|
|
|
|
|
- ctx.font = 'bold 12px sans-serif';
|
|
|
|
|
- const tw = ctx.measureText(label).width;
|
|
|
|
|
- ctx.fillStyle = color;
|
|
|
|
|
- ctx.fillRect(x, y - 20, tw + 8, 20);
|
|
|
|
|
- ctx.fillStyle = '#fff';
|
|
|
|
|
- ctx.fillText(label, x + 4, y - 5);
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
- img.src = frame.imageDataUrl;
|
|
|
|
|
- }, 0);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
- confidencePercent(value: number): string {
|
|
|
|
|
- return (value * 100).toFixed(1) + '%';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- gradeColor(cls: string): string {
|
|
|
|
|
- const palette: Record<string, string> = {
|
|
|
|
|
- Ripe: '#4caf50',
|
|
|
|
|
- Unripe: '#ff9800',
|
|
|
|
|
- Underripe: '#ffeb3b',
|
|
|
|
|
- Overripe: '#9c27b0',
|
|
|
|
|
- Abnormal: '#f44336',
|
|
|
|
|
- Empty_Bunch: '#607d8b',
|
|
|
|
|
- };
|
|
|
|
|
- return palette[cls] ?? '#757575';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- selectFrame(index: number): void {
|
|
|
|
|
- this.selectedFrameIndex = index;
|
|
|
|
|
- this.currentFrame = this.batchFrames[index];
|
|
|
|
|
- if (this.isViewInitialized) {
|
|
|
|
|
- this.renderPredictionsWithBoxes(this.batchFrames[index]);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- prevFrame(): void {
|
|
|
|
|
- const next = this.selectedFrameIndex > 0
|
|
|
|
|
- ? this.selectedFrameIndex - 1
|
|
|
|
|
- : this.batchFrames.length - 1;
|
|
|
|
|
- this.selectFrame(next);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- nextFrame(): void {
|
|
|
|
|
- const next = this.selectedFrameIndex < this.batchFrames.length - 1
|
|
|
|
|
- ? this.selectedFrameIndex + 1
|
|
|
|
|
- : 0;
|
|
|
|
|
- this.selectFrame(next);
|
|
|
|
|
|
|
+ resetScan(): void {
|
|
|
|
|
+ this.stopCamera();
|
|
|
|
|
+ this.activeBatchId = null;
|
|
|
|
|
+ this.currentFrame = null;
|
|
|
|
|
+ this.batchFrames = [];
|
|
|
|
|
+ this.selectedFrameIndex = 0;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private submit(files: File[]): void {
|
|
private submit(files: File[]): void {
|